From 3edc1fe0b3f51c58dbb6e61b475f57a052ae01af Mon Sep 17 00:00:00 2001 From: lsh23 Date: Thu, 16 Nov 2023 13:15:02 +0900 Subject: [PATCH 001/188] =?UTF-8?q?[BE]=20feat:=20achievement=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20title,=20thumbnailUrl=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/achievement.entity.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/BE/src/achievement/entities/achievement.entity.ts b/BE/src/achievement/entities/achievement.entity.ts index 9cbf3d66..e639d38e 100644 --- a/BE/src/achievement/entities/achievement.entity.ts +++ b/BE/src/achievement/entities/achievement.entity.ts @@ -8,9 +8,10 @@ import { } from 'typeorm'; import { UserEntity } from '../../users/entities/user.entity'; import { CategoryEntity } from '../../category/entities/category.entity'; +import { Achievement } from '../domain/achievement.domain'; -@Entity({ name: 'user_achievement' }) -export class UserAchievementEntity extends BaseTimeEntity { +@Entity({ name: 'achievement' }) +export class AchievementEntity extends BaseTimeEntity { @PrimaryGeneratedColumn() id: number; @@ -22,9 +23,40 @@ export class UserAchievementEntity extends BaseTimeEntity { @JoinColumn({ name: 'category_id' }) category: CategoryEntity; + @Column({ type: 'varchar', length: 20 }) + title: string; + @Column({ type: 'text' }) content: string; @Column({ type: 'varchar', length: 100 }) imageUrl: string; + + @Column({ type: 'varchar', length: 100 }) + thumbnailUrl: string; + + toModel() { + const achievement = new Achievement( + this.user.toModel(), + this.category.toModel(), + this.title, + this.content, + this.imageUrl, + this.thumbnailUrl, + ); + achievement.id = this.id; + return achievement; + } + + static from(achievement: Achievement) { + const achievementEntity = new AchievementEntity(); + achievementEntity.id = achievement.id; + achievementEntity.user = UserEntity.from(achievement.user); + achievementEntity.category = CategoryEntity.from(achievement.category); + achievementEntity.title = achievement.title; + achievementEntity.content = achievement.content; + achievementEntity.imageUrl = achievement.imageUrl; + achievementEntity.thumbnailUrl = achievement.thumbnailUrl; + return achievementEntity; + } } From b1fb825607b143c09432a222af5eb041e0e3b044 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Thu, 16 Nov 2023 22:51:03 +0900 Subject: [PATCH 002/188] =?UTF-8?q?[BE]=20test:=20category=20fixture=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/test/category/category-fixture.ts | 18 ++++++++++++++++++ BE/test/category/category-test.module.ts | 14 ++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 BE/test/category/category-fixture.ts create mode 100644 BE/test/category/category-test.module.ts diff --git a/BE/test/category/category-fixture.ts b/BE/test/category/category-fixture.ts new file mode 100644 index 00000000..a3e715d8 --- /dev/null +++ b/BE/test/category/category-fixture.ts @@ -0,0 +1,18 @@ +import { User } from '../../src/users/domain/user.domain'; +import { Injectable } from '@nestjs/common'; +import { Category } from '../../src/category/domain/category.domain'; +import { CategoryRepository } from '../../src/category/entities/category.repository'; + +@Injectable() +export class CategoryFixture { + constructor(private readonly categoryRepository: CategoryRepository) {} + + async getCategory(user: User, name: string): Promise { + const category = CategoryFixture.category(user, name); + return await this.categoryRepository.saveCategory(category); + } + + static category(user: User, name: string) { + return new Category(user, name); + } +} diff --git a/BE/test/category/category-test.module.ts b/BE/test/category/category-test.module.ts new file mode 100644 index 00000000..756ae402 --- /dev/null +++ b/BE/test/category/category-test.module.ts @@ -0,0 +1,14 @@ +import { CustomTypeOrmModule } from '../../src/config/typeorm/custom-typeorm.module'; +import { Module } from '@nestjs/common'; +import { CategoryRepository } from '../../src/category/entities/category.repository'; +import { CategoryModule } from '../../src/category/category.module'; +import { CategoryFixture } from './category-fixture'; + +@Module({ + imports: [ + CustomTypeOrmModule.forCustomRepository([CategoryRepository]), + CategoryModule, + ], + providers: [CategoryFixture], +}) +export class CategoryTestModule {} From efb6ea5f11e640d6e08fea8db3f2bd35d8d59325 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Thu, 16 Nov 2023 22:51:29 +0900 Subject: [PATCH 003/188] =?UTF-8?q?[BE]=20test:=20achievement=20fixture=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/test/achievement/achievement-fixture.ts | 28 +++++++++++++++++++ .../achievement/achievement-test.module.ts | 14 ++++++++++ 2 files changed, 42 insertions(+) create mode 100644 BE/test/achievement/achievement-fixture.ts create mode 100644 BE/test/achievement/achievement-test.module.ts diff --git a/BE/test/achievement/achievement-fixture.ts b/BE/test/achievement/achievement-fixture.ts new file mode 100644 index 00000000..461578d9 --- /dev/null +++ b/BE/test/achievement/achievement-fixture.ts @@ -0,0 +1,28 @@ +import { User } from '../../src/users/domain/user.domain'; +import { Injectable } from '@nestjs/common'; +import { AchievementRepository } from '../../src/achievement/entities/achievement.repository'; +import { Achievement } from '../../src/achievement/domain/achievement.domain'; +import { Category } from '../../src/category/domain/category.domain'; + +@Injectable() +export class AchievementFixture { + static id = 0; + + constructor(private readonly achievementRepository: AchievementRepository) {} + + async getAchievement(user: User, category: Category): Promise { + const achievement = AchievementFixture.achievement(user, category); + return await this.achievementRepository.saveAchievement(achievement); + } + + static achievement(user: User, category: Category) { + return new Achievement( + user, + category, + `다이어트 ${this.id}회차`, + '오늘의 닭가슴살', + `imageUrl${this.id}`, + `thumbnailUrl${this.id}`, + ); + } +} diff --git a/BE/test/achievement/achievement-test.module.ts b/BE/test/achievement/achievement-test.module.ts new file mode 100644 index 00000000..d1c91c23 --- /dev/null +++ b/BE/test/achievement/achievement-test.module.ts @@ -0,0 +1,14 @@ +import { CustomTypeOrmModule } from '../../src/config/typeorm/custom-typeorm.module'; +import { Module } from '@nestjs/common'; +import { AchievementRepository } from '../../src/achievement/entities/achievement.repository'; +import { AchievementModule } from '../../src/achievement/achievement.module'; +import { AchievementFixture } from './achievement-fixture'; + +@Module({ + imports: [ + CustomTypeOrmModule.forCustomRepository([AchievementRepository]), + AchievementModule, + ], + providers: [AchievementFixture], +}) +export class AchievementTestModule {} From 273fc7b85dce0d60a6dd762d9e9b47e53827bc70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Fri, 17 Nov 2023 12:28:21 +0900 Subject: [PATCH 004/188] =?UTF-8?q?[iOS]=20refactor:=20=EC=B4=AC=EC=98=81?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20UI=20=EB=94=94=EC=9E=90=EC=9D=B8?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moti/Application/AppCoordinator.swift | 2 +- .../TabBarItemGray.colorset/Contents.json | 38 +++++++++++++++++++ .../Design/Sources/Design/NormalButton.swift | 30 ++++++++++----- .../Sources/Design/UIColor+MotiColor.swift | 3 ++ .../{Font.swift => UIFont+Extension.swift} | 1 + .../Presentation/Capture/CaptureView.swift | 35 ++++++++--------- 6 files changed, 82 insertions(+), 27 deletions(-) create mode 100644 iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/TabBarItemGray.colorset/Contents.json rename iOS/moti/moti/Design/Sources/Design/{Font.swift => UIFont+Extension.swift} (87%) diff --git a/iOS/moti/moti/Application/AppCoordinator.swift b/iOS/moti/moti/Application/AppCoordinator.swift index 7880568a..31e25329 100644 --- a/iOS/moti/moti/Application/AppCoordinator.swift +++ b/iOS/moti/moti/Application/AppCoordinator.swift @@ -24,7 +24,7 @@ final class AppCoordinator: Coordinator { } func start() { - moveLaunchViewController() + moveHomeViewController() } private func moveLaunchViewController() { diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/TabBarItemGray.colorset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/TabBarItemGray.colorset/Contents.json new file mode 100644 index 00000000..340b8d69 --- /dev/null +++ b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/TabBarItemGray.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "153", + "green" : "153", + "red" : "153" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "140", + "green" : "139", + "red" : "139" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/moti/moti/Design/Sources/Design/NormalButton.swift b/iOS/moti/moti/Design/Sources/Design/NormalButton.swift index 6c55e357..d7a1cba2 100644 --- a/iOS/moti/moti/Design/Sources/Design/NormalButton.swift +++ b/iOS/moti/moti/Design/Sources/Design/NormalButton.swift @@ -10,21 +10,33 @@ import UIKit open class NormalButton: UIButton { public init(title: String? = nil, image: UIImage? = nil) { super.init(frame: .zero) + + configuration = .plain() + setTitle(title, for: .normal) setImage(image, for: .normal) - setTitleColor(.primaryBlue, for: .normal) - setTitleColor(.normalButtonHighlightColor, for: .highlighted) - configuration = .plain() - configuration?.imagePlacement = .top - configuration?.imagePadding = 10 } - override init(frame: CGRect) { - super.init(frame: frame) + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open override func setImage(_ image: UIImage?, for state: UIControl.State) { + super.setImage(image, for: state) + + configuration?.image = image + configuration?.imagePlacement = .top + configuration?.imagePadding = 10 } - public required init?(coder: NSCoder) { - super.init(coder: coder) + open override func setTitle(_ title: String?, for state: UIControl.State) { + super.setTitle(title, for: state) + + configuration?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + outgoing.font = UIFont.xsmall + return outgoing + } } public func setColor(_ color: UIColor) { diff --git a/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift b/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift index de09caa1..7cd784fc 100644 --- a/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift +++ b/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift @@ -28,4 +28,7 @@ public extension UIColor { /// 일반 버튼이 눌렸을 때 색상 static var normalButtonHighlightColor = UIColor(resource: .skyBlue) + + /// 탭바 아이템과 동일한 회색 색상 + static var tabBarItemGray = UIColor(resource: .tabBarItemGray) } diff --git a/iOS/moti/moti/Design/Sources/Design/Font.swift b/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift similarity index 87% rename from iOS/moti/moti/Design/Sources/Design/Font.swift rename to iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift index cc798c83..576485c2 100644 --- a/iOS/moti/moti/Design/Sources/Design/Font.swift +++ b/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift @@ -8,6 +8,7 @@ import UIKit public extension UIFont { + static let xsmall = UIFont.systemFont(ofSize: 10) static let small = UIFont.systemFont(ofSize: 12) static let medium = UIFont.systemFont(ofSize: 14) static let large = UIFont.systemFont(ofSize: 24) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 746c5555..039d6cc5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -58,34 +58,35 @@ final class CaptureView: UIView { } private func setupUI() { + setupPreview() + + setupCaptureButton() setupPhotoButton() setupCameraSwitchingButton() - setupCaptureButton() - setupPreview() } + private func setupCaptureButton() { + addSubview(captureButton) + captureButton.atl + .size(width: CaptureButton.defaultSize, height: CaptureButton.defaultSize) + .centerX(equalTo: centerXAnchor) + .bottom(equalTo: bottomAnchor, constant: -36) + } + private func setupPhotoButton() { - photoButton.setColor(.lightGray) + photoButton.setColor(.tabBarItemGray) addSubview(photoButton) photoButton.atl - .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -20) - .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 15) + .bottom(equalTo: captureButton.bottomAnchor) + .right(equalTo: captureButton.leftAnchor, constant: -30) } private func setupCameraSwitchingButton() { - cameraSwitchingButton.setColor(.lightGray) + cameraSwitchingButton.setColor(.tabBarItemGray) addSubview(cameraSwitchingButton) cameraSwitchingButton.atl - .bottom(equalTo: photoButton.bottomAnchor) - .right(equalTo: safeAreaLayoutGuide.rightAnchor, constant: -15) - } - - private func setupCaptureButton() { - addSubview(captureButton) - captureButton.atl - .size(width: CaptureButton.defaultSize, height: CaptureButton.defaultSize) - .centerX(equalTo: centerXAnchor) - .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -36) + .bottom(equalTo: captureButton.bottomAnchor) + .left(equalTo: captureButton.rightAnchor, constant: 30) } private func setupResultImageView() { @@ -104,7 +105,7 @@ final class CaptureView: UIView { .height(equalTo: preview.widthAnchor) // PreviewLayer를 Preview 에 넣기 - previewLayer.backgroundColor = UIColor.lightGray.cgColor + previewLayer.backgroundColor = UIColor.primaryGray.cgColor previewLayer.videoGravity = .resizeAspectFill preview.layer.addSublayer(previewLayer) } From 1bea4b5c9e4b7d3920b03441c58dfb35ffe1b119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=A0=95=EC=A3=BC=20JeongJu=20Yu?= Date: Fri, 17 Nov 2023 12:31:33 +0900 Subject: [PATCH 005/188] =?UTF-8?q?docs:=20README=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cf445c99..58ec4007 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,25 @@ -# moti - +

-- 기존의 사진 목표 달성 앱은 불필요한 과정이 너무 많아서 **귀찮았어요** 😢 -- 촬영만 하면 **알아서 해주는** 사진 목표 달성 앱을 만들어보기로 했습니다 🔥 -- 모티 앱은 **빠르고 간편하게** 사진 목표 달성을 기록할 수 있습니다 😊 +

moti

+ +
+도전을 사진으로 기록하는 앱
+도전을 빠르고 간편하게 기록해 보세요.
+애플다운 자연스러운 사용성도 느낄 수 있습니다. +

# 🖼️ 스크린샷 + +
TODO +

# 🔥 팀원 소개 +
@@ -43,10 +50,12 @@ TODO
+

-# 📔 문서 -| 팀 노션 | 그라운드룰 | 기획/디자인 | 템플릿 | 회의록 | -|---|---|---|---|---| -| [팀 노션](https://jeong9216.notion.site/moti-003002603e5e49c48750d83668508c8e?pvs=4) | [그라운드룰](https://jeong9216.notion.site/abdf3f7229fe469186dcf11e2ba686bd?pvs=4) | [기획/디자인](https://www.figma.com/file/Qeluz7lzMO7igCvL26BymS/%EB%AA%A8%ED%8B%B0?type=design&node-id=0%3A1&mode=design&t=0no8SURD3YhZBxfp-1) | [템플릿](https://jeong9216.notion.site/dc911fa357ef4063859d8650ba46e30a?pvs=4) | [회의록](https://jeong9216.notion.site/116f25437164432db1e34ab534fe8069?pvs=4) | +# 📔 노션 문서 + +| 팀 노션 | 백로그 | 그라운드룰 | 기획/디자인 | 템플릿 | 회의록 | +|---|---|---|---|---|---| +| [팀 노션](https://jeong9216.notion.site/moti-003002603e5e49c48750d83668508c8e?pvs=4) | [백로그](https://github.com/orgs/boostcampwm2023/projects/109/views/2) | [그라운드룰](https://jeong9216.notion.site/abdf3f7229fe469186dcf11e2ba686bd?pvs=4) | [기획/디자인](https://www.figma.com/file/Qeluz7lzMO7igCvL26BymS/%EB%AA%A8%ED%8B%B0?type=design&node-id=0%3A1&mode=design&t=0no8SURD3YhZBxfp-1) | [템플릿](https://jeong9216.notion.site/dc911fa357ef4063859d8650ba46e30a?pvs=4) | [회의록](https://jeong9216.notion.site/116f25437164432db1e34ab534fe8069?pvs=4) | From 925d71f3a50ab43cefcd39c8e4dad7b0ef1ef659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Fri, 17 Nov 2023 14:53:39 +0900 Subject: [PATCH 006/188] =?UTF-8?q?[iOS]=20feat:=20session,=20output=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=B6=94=EA=B0=80=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - photo 모드로 고정 - 세로로만 촬영하도록 속성 추가 - 화면이 사라지면 session을 종료하도록 수정 - image crop --- .../Presentation/Capture/CaptureView.swift | 18 ++++-- .../Capture/CaptureViewController.swift | 59 +++++++++++++++---- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 039d6cc5..eddc1aca 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -16,15 +16,23 @@ final class CaptureView: UIView { private let cameraSwitchingButton = NormalButton(title: "카메라 전환", image: SymbolImage.iphone) // Video Preview - private let previewTopPadding: CGFloat = 100 - private let previewLayer = AVCaptureVideoPreviewLayer() - private let preview = UIView() + private let previewLayer = { + let previewLayer = AVCaptureVideoPreviewLayer() + previewLayer.videoGravity = .resizeAspectFill + // portrait 고정 + if #available(iOS 17.0, *) { + previewLayer.connection?.videoRotationAngle = 90 + } else { + previewLayer.connection?.videoOrientation = .portrait + } + return previewLayer + }() + let preview = UIView() let captureButton = CaptureButton() // VC에서 액션을 달아주기 위해 private 제거 private let resultImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true imageView.isHidden = true return imageView }() @@ -99,7 +107,7 @@ final class CaptureView: UIView { // 카메라 Preview addSubview(preview) preview.atl - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: previewTopPadding) + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) .left(equalTo: safeAreaLayoutGuide.leftAnchor) .right(equalTo: safeAreaLayoutGuide.rightAnchor) .height(equalTo: preview.widthAnchor) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index a4438268..f8d356d7 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -25,10 +25,19 @@ final class CaptureViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() addTargets() - + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) checkCameraPermissions() } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + Logger.debug("Session Stop Running") + session?.stopRunning() + } + // MARK: - Methods private func addTargets() { layoutView.captureButton.addTarget(self, action: #selector(didClickedShutterButton), for: .touchUpInside) @@ -57,9 +66,11 @@ final class CaptureViewController: BaseViewController { } private func setupCamera() { + guard let device = AVCaptureDevice.default(for: .video) else { return } + // 세션을 만들고 input, output 연결 let session = AVCaptureSession() - guard let device = AVCaptureDevice.default(for: .video) else { return } + session.sessionPreset = .photo do { let input = try AVCaptureDeviceInput(device: device) if session.canAddInput(input) { @@ -69,10 +80,11 @@ final class CaptureViewController: BaseViewController { if session.canAddOutput(output) { session.addOutput(output) } - + layoutView.updatePreviewLayer(session: session) DispatchQueue.global().async { + Logger.debug("Session Start Running") session.startRunning() } self.session = session @@ -83,29 +95,52 @@ final class CaptureViewController: BaseViewController { } @objc private func didClickedShutterButton() { + // 사진 찍기! #if targetEnvironment(simulator) // Simulator Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") #else + // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 + // - speed: 약간의 노이즈 감소만이 적용 + // - balanced: speed보다 약간 더 느리지만 더 나은 품질을 얻음 + // - quality: 현대 디바이스나 밝기에 따라 많은 시간을 사용하여 최상의 품질을 만듬 + + // 빠른 속도를 위해 speed를 사용하려 했지만 + // WWDC 2021 - Capture high-quality photos using video formats에서 speed보다 balanced를 추천 (기본이 balanced임) + // 만약 사진과 비디오가 동일하게 보여야 하면 speed를 사용 + // Actual Device - output.capturePhoto(with: AVCapturePhotoSettings(), - delegate: self) + let setting = AVCapturePhotoSettings() + setting.photoQualityPrioritization = .balanced + output.capturePhoto(with: setting, delegate: self) #endif } } extension CaptureViewController: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { - guard let data = photo.fileDataRepresentation(), - let image = UIImage(data: data) else { return } - Logger.debug("이미지 사이즈: \(image.size)") - Logger.debug("이미지 용량: \(data)") - Logger.debug("이미지 용량: \(data.count / 1000) KB\n") - // 카메라 세션 끊기, 끊지 않으면 여러번 사진 찍기 가능 session?.stopRunning() - layoutView.updatePreview(with: image) + guard let data = photo.fileDataRepresentation(), + let image = UIImage(data: data) else { return } + + #if DEBUG + Logger.debug("이미지 사이즈: \(image.size)") + Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") + Logger.debug("Crop 사이즈: \(layoutView.preview.bounds)") + #endif + + layoutView.updatePreview(with: cropImage(image: image, rect: layoutView.preview.bounds)) + } + + private func cropImage(image: UIImage, rect: CGRect) -> UIImage { + guard let imageRef = image.cgImage?.cropping(to: rect) else { + return image + } + + let croppedImage = UIImage(cgImage: imageRef) + return croppedImage } } From 6eb6a8e8b17d459f1412993b7fc955cad4f07d87 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Fri, 17 Nov 2023 20:32:51 +0900 Subject: [PATCH 007/188] =?UTF-8?q?[BE]=20feat:=20achievement=20domain=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../achievement/domain/achievement.domain.ts | 42 +++++++++++++++++++ .../entities/achievement.entity.ts | 4 +- 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 BE/src/achievement/domain/achievement.domain.ts diff --git a/BE/src/achievement/domain/achievement.domain.ts b/BE/src/achievement/domain/achievement.domain.ts new file mode 100644 index 00000000..3b245d89 --- /dev/null +++ b/BE/src/achievement/domain/achievement.domain.ts @@ -0,0 +1,42 @@ +import { User } from '../../users/domain/user.domain'; +import { Category } from '../../category/domain/category.domain'; + +export class Achievement { + id: number; + + user: User; + + category: Category; + + title: string; + + content: string; + + imageUrl: string; + + thumbnailUrl: string; + + constructor( + user: User, + category: Category, + title: string, + content: string, + imageUrl: string, + thumbnailUrl: string, + ) { + this.user = user; + this.category = category; + this.title = title; + this.content = content; + this.imageUrl = imageUrl; + this.thumbnailUrl = thumbnailUrl; + } + + assignUser(user: User) { + this.user = user; + } + + assignCategory(category: Category) { + this.category = category; + } +} diff --git a/BE/src/achievement/entities/achievement.entity.ts b/BE/src/achievement/entities/achievement.entity.ts index e639d38e..9e1249f5 100644 --- a/BE/src/achievement/entities/achievement.entity.ts +++ b/BE/src/achievement/entities/achievement.entity.ts @@ -37,8 +37,8 @@ export class AchievementEntity extends BaseTimeEntity { toModel() { const achievement = new Achievement( - this.user.toModel(), - this.category.toModel(), + this.user?.toModel(), + this.category?.toModel(), this.title, this.content, this.imageUrl, From 9be64dd9f830880e2383b8a4e893f2385dbe05d8 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Fri, 17 Nov 2023 20:34:04 +0900 Subject: [PATCH 008/188] =?UTF-8?q?[BE]=20feat:=20achievement=20repository?= =?UTF-8?q?=EB=A5=BC=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/achievement.repository.spec.ts | 108 ++++++++++++++++++ .../entities/achievement.repository.ts | 40 +++++++ 2 files changed, 148 insertions(+) create mode 100644 BE/src/achievement/entities/achievement.repository.spec.ts create mode 100644 BE/src/achievement/entities/achievement.repository.ts diff --git a/BE/src/achievement/entities/achievement.repository.spec.ts b/BE/src/achievement/entities/achievement.repository.spec.ts new file mode 100644 index 00000000..ab730373 --- /dev/null +++ b/BE/src/achievement/entities/achievement.repository.spec.ts @@ -0,0 +1,108 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CustomTypeOrmModule } from '../../config/typeorm/custom-typeorm.module'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { typeOrmModuleOptions } from '../../config/typeorm'; +import { configServiceModuleOptions } from '../../config/config'; +import { DataSource } from 'typeorm'; +import { transactionTest } from '../../../test/common/transaction-test'; +import { + AchievementPaginationOption, + AchievementRepository, +} from './achievement.repository'; +import { UsersFixture } from '../../../test/user/users-fixture'; +import { UsersTestModule } from '../../../test/user/users-test.module'; +import { CategoryRepository } from '../../category/entities/category.repository'; +import { CategoryFixture } from '../../../test/category/category-fixture'; +import { AchievementTestModule } from '../../../test/achievement/achievement-test.module'; +import { AchievementFixture } from '../../../test/achievement/achievement-fixture'; +import { CategoryTestModule } from '../../../test/category/category-test.module'; +import { Achievement } from '../domain/achievement.domain'; + +describe('AchievementRepository test', () => { + let achievementRepository: AchievementRepository; + let dataSource: DataSource; + let usersFixture: UsersFixture; + let categoryFixture: CategoryFixture; + let achievementFixture: AchievementFixture; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(configServiceModuleOptions), + TypeOrmModule.forRootAsync(typeOrmModuleOptions), + CustomTypeOrmModule.forCustomRepository([ + AchievementRepository, + CategoryRepository, + ]), + UsersTestModule, + CategoryTestModule, + AchievementTestModule, + ], + }).compile(); + + usersFixture = module.get(UsersFixture); + categoryFixture = module.get(CategoryFixture); + achievementFixture = module.get(AchievementFixture); + achievementRepository = module.get( + AchievementRepository, + ); + dataSource = module.get(DataSource); + }); + + afterAll(async () => { + await dataSource.destroy(); + }); + + test('달성 기록을 저장할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, '카테고리1'); + const achievement = new Achievement( + user, + category, + '다이어트 1회차', + '오늘의 닭가슴살', + 'imageUrl', + 'thumbnailUrl', + ); + + // when + const expected = await achievementRepository.saveAchievement(achievement); + + // then + expect(expected.title).toEqual('다이어트 1회차'); + expect(expected.content).toEqual('오늘의 닭가슴살'); + expect(expected.imageUrl).toEqual('imageUrl'); + expect(expected.thumbnailUrl).toEqual('thumbnailUrl'); + }); + }); + + test('달성 기록을 조회한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + const achievements = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + + // when + const achievementPaginationOption: AchievementPaginationOption = { + categoryId: category.id, + take: 12, + }; + const findAll = await achievementRepository.findAll( + user.id, + achievementPaginationOption, + ); + + // then + expect(findAll.length).toEqual(10); + }); + }); +}); diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts new file mode 100644 index 00000000..72f2dbe0 --- /dev/null +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -0,0 +1,40 @@ +import { CustomRepository } from '../../config/typeorm/custom-repository.decorator'; +import { TransactionalRepository } from '../../config/transaction-manager/transactional-repository'; +import { AchievementEntity } from './achievement.entity'; +import { Achievement } from '../domain/achievement.domain'; +import { FindOptionsWhere, LessThan } from 'typeorm'; + +export interface AchievementPaginationOption { + categoryId: number; + where__id__less_than?: number; + take: number; +} +@CustomRepository(AchievementEntity) +export class AchievementRepository extends TransactionalRepository { + async findAll( + userId: number, + achievementPaginationOption: AchievementPaginationOption, + ): Promise { + const where: FindOptionsWhere = { + user: { id: userId }, + category: { id: achievementPaginationOption.categoryId }, + }; + if (achievementPaginationOption.where__id__less_than) { + where.id = LessThan(achievementPaginationOption.where__id__less_than); + } + const achievementEntities = await this.repository.find({ + where, + order: { createdAt: 'DESC' }, + take: achievementPaginationOption.take, + }); + return achievementEntities.map((achievementEntity) => + achievementEntity.toModel(), + ); + } + + async saveAchievement(achievement: Achievement): Promise { + const achievementEntity = AchievementEntity.from(achievement); + const saved = await this.repository.save(achievementEntity); + return saved.toModel(); + } +} From ae1ae5288614a5bc177eded145d24eaf6135b2d5 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Fri, 17 Nov 2023 20:53:19 +0900 Subject: [PATCH 009/188] =?UTF-8?q?[BE]=20fix:=20achievement=20fixture=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=8B=9C=EC=97=90=20id=EA=B0=80=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EC=A6=9D=EA=B0=80=20=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/test/achievement/achievement-fixture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BE/test/achievement/achievement-fixture.ts b/BE/test/achievement/achievement-fixture.ts index 461578d9..6ba9283c 100644 --- a/BE/test/achievement/achievement-fixture.ts +++ b/BE/test/achievement/achievement-fixture.ts @@ -19,7 +19,7 @@ export class AchievementFixture { return new Achievement( user, category, - `다이어트 ${this.id}회차`, + `다이어트 ${++this.id}회차`, '오늘의 닭가슴살', `imageUrl${this.id}`, `thumbnailUrl${this.id}`, From 661861fca0c6f933e9e24323f4b78a320a3669c2 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Fri, 17 Nov 2023 21:04:35 +0900 Subject: [PATCH 010/188] =?UTF-8?q?[BE]=20feat:=20achievement=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EA=B8=B0=EB=B3=B8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?dto=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../achievement/dto/achievement-response.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 BE/src/achievement/dto/achievement-response.ts diff --git a/BE/src/achievement/dto/achievement-response.ts b/BE/src/achievement/dto/achievement-response.ts new file mode 100644 index 00000000..0591b177 --- /dev/null +++ b/BE/src/achievement/dto/achievement-response.ts @@ -0,0 +1,21 @@ +import { Achievement } from '../domain/achievement.domain'; + +export class AchievementResponse { + id: number; + thumbnailUrl: string; + title: string; + + constructor(id: number, thumbnailUrl: string, title: string) { + this.id = id; + this.thumbnailUrl = thumbnailUrl; + this.title = title; + } + + static from(achievement: Achievement) { + return new AchievementResponse( + achievement.id, + achievement.thumbnailUrl, + achievement.title, + ); + } +} From d044adfaa69d09d74c8de71435a75a31ada25ae1 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Fri, 17 Nov 2023 21:32:12 +0900 Subject: [PATCH 011/188] =?UTF-8?q?[BE]=20feat:=20achievement=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=8C=80=ED=95=9C=20pagination?= =?UTF-8?q?=20dto=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/achievement-detail-response.ts | 9 ++++ .../dto/paginate-achievement-request.ts | 25 +++++++++ .../dto/paginate-achievement-response.ts | 53 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 BE/src/achievement/dto/achievement-detail-response.ts create mode 100644 BE/src/achievement/dto/paginate-achievement-request.ts create mode 100644 BE/src/achievement/dto/paginate-achievement-response.ts diff --git a/BE/src/achievement/dto/achievement-detail-response.ts b/BE/src/achievement/dto/achievement-detail-response.ts new file mode 100644 index 00000000..6d519feb --- /dev/null +++ b/BE/src/achievement/dto/achievement-detail-response.ts @@ -0,0 +1,9 @@ +export class AchievementDetailResponse { + id: number; + imageUrl: string; + title: string; + content: string; + categoryName: string; + categoryRoundCnt: string; + createdAt: string; +} diff --git a/BE/src/achievement/dto/paginate-achievement-request.ts b/BE/src/achievement/dto/paginate-achievement-request.ts new file mode 100644 index 00000000..31331b48 --- /dev/null +++ b/BE/src/achievement/dto/paginate-achievement-request.ts @@ -0,0 +1,25 @@ +import { IsNumber, IsOptional } from 'class-validator'; + +export class PaginateAchievementRequest { + @IsNumber() + @IsOptional() + where__id__less_than?: number; + + @IsNumber() + @IsOptional() + take: number = 12; + + @IsNumber() + @IsOptional() + categoryId: number; + + constructor( + categoryId?: number, + take?: number, + where__id__less_than?: number, + ) { + this.categoryId = categoryId; + this.take = take; + this.where__id__less_than = where__id__less_than; + } +} diff --git a/BE/src/achievement/dto/paginate-achievement-response.ts b/BE/src/achievement/dto/paginate-achievement-response.ts new file mode 100644 index 00000000..ff6b0d02 --- /dev/null +++ b/BE/src/achievement/dto/paginate-achievement-response.ts @@ -0,0 +1,53 @@ +import { AchievementResponse } from './achievement-response'; +import { PaginateAchievementRequest } from './paginate-achievement-request'; + +export class PaginateAchievementResponse { + private basePath = '/api/v1/achievements?'; + data: AchievementResponse[]; + cursor: { + after: number; + }; + count: number; + next: string; + + constructor( + paginateAchievementRequest: PaginateAchievementRequest, + achievements: AchievementResponse[], + ) { + this.data = achievements; + + const last = + achievements.length > 0 && + achievements.length === paginateAchievementRequest.take + ? achievements[achievements.length - 1] + : null; + + this.cursor = { + after: last?.id ?? null, + }; + + this.count = achievements.length; + this.next = this.makeNextUrl(paginateAchievementRequest, last); + } + + private makeNextUrl( + paginateAchievementRequest: PaginateAchievementRequest, + last: AchievementResponse, + ) { + const nextUrl = last && [this.basePath]; + + if (nextUrl) { + for (const key of Object.keys(paginateAchievementRequest)) { + if (!paginateAchievementRequest[key]) { + continue; + } + if (key !== 'where__id__less_than') { + nextUrl.push(`${key}=${paginateAchievementRequest[key]}`); + } + } + nextUrl.push(`where__id__less_than=${last.id.toString()}`); + } + + return nextUrl?.join('&').toString() ?? null; + } +} From ba4dbcf6a3ed148860aad9c77f96649a1d970fbb Mon Sep 17 00:00:00 2001 From: lsh23 Date: Fri, 17 Nov 2023 21:32:56 +0900 Subject: [PATCH 012/188] =?UTF-8?q?[BE]=20test:=20PaginateAchievementRespo?= =?UTF-8?q?nse=20dto=EC=9D=98=20next=20url=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=A5=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/paginate-achievement-response.spec.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 BE/src/achievement/dto/paginate-achievement-response.spec.ts diff --git a/BE/src/achievement/dto/paginate-achievement-response.spec.ts b/BE/src/achievement/dto/paginate-achievement-response.spec.ts new file mode 100644 index 00000000..46d777a4 --- /dev/null +++ b/BE/src/achievement/dto/paginate-achievement-response.spec.ts @@ -0,0 +1,37 @@ +import { PaginateAchievementResponse } from './paginate-achievement-response'; +import { AchievementResponse } from './achievement-response'; +import { PaginateAchievementRequest } from './paginate-achievement-request'; + +describe('PaginateAchievementResponse test', () => { + const achievementResponses: AchievementResponse[] = []; + + for (let i = 99; i >= 0; i--) { + achievementResponses.push( + new AchievementResponse(i, `thumbnail${i}`, `title${i}`), + ); + } + + test('next url을 생성한다.', () => { + const paginateAchievementRequest = new PaginateAchievementRequest(); + paginateAchievementRequest.take = 12; + const response = new PaginateAchievementResponse( + paginateAchievementRequest, + achievementResponses.slice(0, paginateAchievementRequest.take), + ); + expect(response.next).toEqual( + '/api/v1/achievements?&take=12&where__id__less_than=88', + ); + }); + test('next url을 생성한다. - categoryId 필터링 추가', () => { + const paginateAchievementRequest = new PaginateAchievementRequest(); + paginateAchievementRequest.take = 12; + paginateAchievementRequest.categoryId = 1; + const response = new PaginateAchievementResponse( + paginateAchievementRequest, + achievementResponses.slice(0, paginateAchievementRequest.take), + ); + expect(response.next).toEqual( + '/api/v1/achievements?&take=12&categoryId=1&where__id__less_than=88', + ); + }); +}); From 0cc75e7e4e866492280d36b468280918a946773d Mon Sep 17 00:00:00 2001 From: lsh23 Date: Fri, 17 Nov 2023 22:04:33 +0900 Subject: [PATCH 013/188] =?UTF-8?q?[BE]=20feat:=20enableImplicitConversion?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dto 객체에서 @IsNumber 데코레이터를 사용하는 필드를 자동으로 number로 형변환 해준다. (query param의 경우 모든 값이 string으로 넘어오기 때문에 query param이 많은 요청의 경우 dto 클래스로 만들고 해당 옵션을 적용해주면 유용하다) --- BE/src/config/validation/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/BE/src/config/validation/index.ts b/BE/src/config/validation/index.ts index 3da44aea..2bfafb2d 100644 --- a/BE/src/config/validation/index.ts +++ b/BE/src/config/validation/index.ts @@ -9,6 +9,9 @@ export const validationPipeOptions: ValidationPipeOptions = { whitelist: true, forbidNonWhitelisted: true, transform: true, + transformOptions: { + enableImplicitConversion: true, + }, exceptionFactory: (errors: ValidationError[]) => { const defaultErrorMessage: ValidationErrorMessage = { error: '잘못된 입력입니다.', From dbc70366386707449b431221cd1b5897e1a010c0 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sat, 18 Nov 2023 09:15:57 +0900 Subject: [PATCH 014/188] =?UTF-8?q?[BE]=20refactor:=20adminService=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - userFixture를 사용하도록 변경 --- .../admin/application/admin.service.spec.ts | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/BE/src/admin/application/admin.service.spec.ts b/BE/src/admin/application/admin.service.spec.ts index 23e607b4..accb2da7 100644 --- a/BE/src/admin/application/admin.service.spec.ts +++ b/BE/src/admin/application/admin.service.spec.ts @@ -8,23 +8,22 @@ import { DataSource } from 'typeorm'; import { AdminModule } from '../admin.module'; import { PlainTextPasswordEncoder } from './plain-text-password-encoder'; import { PasswordEncoder } from './password-encoder'; -import { UsersModule } from '../../users/users.module'; -import { UserRepository } from '../../users/entities/user.repository'; import { transactionTest } from '../../../test/common/transaction-test'; -import { User } from '../../users/domain/user.domain'; import { AdminRegister } from '../dto/admin-register'; import { AdminStatus } from '../domain/admin-status'; import { Admin } from '../domain/admin.domain'; import { AdminRepository } from '../entities/admin.repository'; import { AdminLogin } from '../dto/admin-login'; -import { AdminInvalidPasswordException } from '../exception/admin-invalid-password'; -import { UserAlreadyRegisteredAdmin } from '../exception/user-already-registered-admin'; +import { AdminInvalidPasswordException } from '../exception/admin-invalid-password.exception'; +import { UserAlreadyRegisteredAdminException } from '../exception/user-already-registered-admin.exception'; +import { UsersTestModule } from '../../../test/user/users-test.module'; +import { UsersFixture } from '../../../test/user/users-fixture'; describe('AdminService Test', () => { let adminService: AdminService; let passwordEncoder: PasswordEncoder; let dataSource: DataSource; - let userRepository: UserRepository; + let usersFixture: UsersFixture; let adminRepository: AdminRepository; beforeAll(async () => { @@ -32,12 +31,12 @@ describe('AdminService Test', () => { imports: [ TypeOrmModule.forRootAsync(typeOrmModuleOptions), AdminModule, - UsersModule, + UsersTestModule, ConfigModule.forRoot(configServiceModuleOptions), ], }).compile(); - userRepository = app.get(UserRepository); + usersFixture = app.get(UsersFixture); adminRepository = app.get(AdminRepository); adminService = app.get(AdminService); passwordEncoder = app.get(PasswordEncoder); @@ -56,15 +55,12 @@ describe('AdminService Test', () => { it('registerAdmin은 관리자로 등록 신청한다.', async () => { await transactionTest(dataSource, async () => { // given - const user = new User(); - user.assignUserCode('ABCEAQ2'); - user.userIdentifier = '123'; - const savedUser = await userRepository.saveUser(user); + const user = await usersFixture.getUser('ABC'); const adminRegister = new AdminRegister('abc@abc.com', '1234', '1234'); // when - const admin = await adminService.registerAdmin(adminRegister, savedUser); + const admin = await adminService.registerAdmin(adminRegister, user); // then expect(admin).toBeDefined(); @@ -76,11 +72,8 @@ describe('AdminService Test', () => { it('registerAdmin은 이미 관리자로 등록된 유저의 요청에 UserAlreadyRegisteredAdmin를 발생시킨다.', async () => { await transactionTest(dataSource, async () => { // given - const user = new User(); - user.assignUserCode('ABCEAQ2'); - user.userIdentifier = '123'; - const savedUser = await userRepository.saveUser(user); - const savedAdmin = new Admin(savedUser, 'abc123@abc.com', '1234'); + const user = await usersFixture.getUser('ABC'); + const savedAdmin = new Admin(user, 'abc123@abc.com', '1234'); savedAdmin.status = AdminStatus.ACTIVE; await adminRepository.saveAdmin(savedAdmin); @@ -89,19 +82,16 @@ describe('AdminService Test', () => { // when // then await expect( - adminService.registerAdmin(adminRegister, savedUser), - ).rejects.toThrow(UserAlreadyRegisteredAdmin); + adminService.registerAdmin(adminRegister, user), + ).rejects.toThrow(UserAlreadyRegisteredAdminException); }); }); it('loginAdmin은 ACTIVE 상태의 어드민이 올바른 email, password 요청에서 인증 토큰을 부여한다.', async () => { await transactionTest(dataSource, async () => { // given - const user = new User(); - user.assignUserCode('ABCEAQ2'); - user.userIdentifier = '123'; - const savedUser = await userRepository.saveUser(user); - const admin = new Admin(savedUser, 'abc123@abc.com', '1234'); + const user = await usersFixture.getUser('ABC'); + const admin = new Admin(user, 'abc123@abc.com', '1234'); admin.status = AdminStatus.ACTIVE; await adminRepository.saveAdmin(admin); const adminLogin = new AdminLogin('abc123@abc.com', '1234'); @@ -117,11 +107,8 @@ describe('AdminService Test', () => { it('loginAdmin은 ACTIVE 상태의 어드민이 올바르지 않은 email, password에서 AdminInvalidPasswordException를 발생', async () => { await transactionTest(dataSource, async () => { // given - const user = new User(); - user.assignUserCode('ABCEAQ2'); - user.userIdentifier = '123'; - const savedUser = await userRepository.saveUser(user); - const admin = new Admin(savedUser, 'abc123@abc.com', '1234'); + const user = await usersFixture.getUser('ABC'); + const admin = new Admin(user, 'abc123@abc.com', '1234'); admin.status = AdminStatus.ACTIVE; await adminRepository.saveAdmin(admin); const adminLogin = new AdminLogin('abc123@abc.com', '12345'); From 24ba742b7745d516ff704bc51fb0e85e17bdf47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Sun, 19 Nov 2023 11:02:47 +0900 Subject: [PATCH 015/188] =?UTF-8?q?[iOS]=20refactor:=20=EC=98=A4=ED=86=A0?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AutoLayout/AutoLayoutWrapper+UIView.swift | 75 +++++++++++++++---- .../Presentation/Capture/CaptureView.swift | 6 +- .../Presentation/Home/Cell/HeaderView.swift | 2 +- .../Sources/Presentation/Home/HomeView.swift | 6 +- 4 files changed, 68 insertions(+), 21 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift index 57e95d45..f174a63c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift @@ -112,48 +112,95 @@ extension AutoLayoutWrapper { return self } + // MARK: View @discardableResult func center( - of parentView: UIView + of basedView: UIView ) -> Self { view.translatesAutoresizingMaskIntoConstraints = false - view.centerXAnchor.constraint(equalTo: parentView.centerXAnchor).isActive = true - view.centerYAnchor.constraint(equalTo: parentView.centerYAnchor).isActive = true + view.centerXAnchor.constraint(equalTo: basedView.centerXAnchor).isActive = true + view.centerYAnchor.constraint(equalTo: basedView.centerYAnchor).isActive = true return self } @discardableResult func vertical( - equalTo anchor: NSLayoutAnchor, + equalTo basedView: UIView, constant: CGFloat = 0 ) -> Self { view.translatesAutoresizingMaskIntoConstraints = false - view.topAnchor.constraint(equalTo: anchor, constant: constant).isActive = true - view.bottomAnchor.constraint(equalTo: anchor, constant: -constant).isActive = true + view.topAnchor.constraint(equalTo: basedView.topAnchor, constant: constant).isActive = true + view.bottomAnchor.constraint(equalTo: basedView.bottomAnchor, constant: -constant).isActive = true return self } @discardableResult func horizontal( - equalTo anchor: NSLayoutAnchor, + equalTo basedView: UIView, constant: CGFloat = 0 ) -> Self { view.translatesAutoresizingMaskIntoConstraints = false - view.leftAnchor.constraint(equalTo: anchor, constant: constant).isActive = true - view.rightAnchor.constraint(equalTo: anchor, constant: -constant).isActive = true + view.leftAnchor.constraint(equalTo: basedView.leftAnchor, constant: constant).isActive = true + view.rightAnchor.constraint(equalTo: basedView.rightAnchor, constant: -constant).isActive = true + return self + } + + @discardableResult + func all( + of basedView: UIView, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.topAnchor.constraint(equalTo: basedView.topAnchor, constant: constant).isActive = true + view.bottomAnchor.constraint(equalTo: basedView.bottomAnchor, constant: -constant).isActive = true + view.leftAnchor.constraint(equalTo: basedView.leftAnchor, constant: constant).isActive = true + view.rightAnchor.constraint(equalTo: basedView.rightAnchor, constant: -constant).isActive = true + return self + } + + // MARK: Safe Area + @discardableResult + func center( + of safeAreaGuide: UILayoutGuide + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.centerXAnchor.constraint(equalTo: safeAreaGuide.centerXAnchor).isActive = true + view.centerYAnchor.constraint(equalTo: safeAreaGuide.centerYAnchor).isActive = true + return self + } + + @discardableResult + func vertical( + equalTo safeAreaGuide: UILayoutGuide, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.topAnchor.constraint(equalTo: safeAreaGuide.topAnchor, constant: constant).isActive = true + view.bottomAnchor.constraint(equalTo: safeAreaGuide.bottomAnchor, constant: -constant).isActive = true + return self + } + + @discardableResult + func horizontal( + equalTo safeAreaGuide: UILayoutGuide, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.leftAnchor.constraint(equalTo: safeAreaGuide.leftAnchor, constant: constant).isActive = true + view.rightAnchor.constraint(equalTo: safeAreaGuide.rightAnchor, constant: -constant).isActive = true return self } @discardableResult func all( - of parentView: UIView, + equalTo safeAreaGuide: UILayoutGuide, constant: CGFloat = 0 ) -> Self { view.translatesAutoresizingMaskIntoConstraints = false - view.topAnchor.constraint(equalTo: parentView.topAnchor, constant: constant).isActive = true - view.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, constant: -constant).isActive = true - view.leftAnchor.constraint(equalTo: parentView.leftAnchor, constant: constant).isActive = true - view.rightAnchor.constraint(equalTo: parentView.rightAnchor, constant: -constant).isActive = true + view.topAnchor.constraint(equalTo: safeAreaGuide.topAnchor, constant: constant).isActive = true + view.bottomAnchor.constraint(equalTo: safeAreaGuide.bottomAnchor, constant: -constant).isActive = true + view.leftAnchor.constraint(equalTo: safeAreaGuide.leftAnchor, constant: constant).isActive = true + view.rightAnchor.constraint(equalTo: safeAreaGuide.rightAnchor, constant: -constant).isActive = true return self } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index eddc1aca..fec20009 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -107,10 +107,10 @@ final class CaptureView: UIView { // 카메라 Preview addSubview(preview) preview.atl - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) - .left(equalTo: safeAreaLayoutGuide.leftAnchor) - .right(equalTo: safeAreaLayoutGuide.rightAnchor) .height(equalTo: preview.widthAnchor) + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) + .horizontal(equalTo: safeAreaLayoutGuide) + // PreviewLayer를 Preview 에 넣기 previewLayer.backgroundColor = UIColor.primaryGray.cgColor diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift index d4526e6f..d3210654 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift @@ -24,7 +24,7 @@ final class HeaderView: UICollectionViewCell { private var titleLabel: UILabel = { let label = UILabel() - label.text = "달성" + label.text = "성공" label.font = .xlarge return label }() diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift index d92cbde1..6de2a674 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift @@ -65,7 +65,7 @@ final class HomeView: UIView { addSubview(catergoryAddButton) catergoryAddButton.atl .size(width: 37, height: 37) - .top(equalTo: self.safeAreaLayoutGuide.topAnchor) + .top(equalTo: self.safeAreaLayoutGuide.topAnchor, constant: 10) .left(equalTo: self.safeAreaLayoutGuide.leftAnchor, constant: 10) addSubview(separatorView) @@ -80,7 +80,7 @@ final class HomeView: UIView { addSubview(categoryCollectionView) categoryCollectionView.atl .height(constant: 37) - .top(equalTo: self.safeAreaLayoutGuide.topAnchor) + .top(equalTo: self.safeAreaLayoutGuide.topAnchor, constant: 10) .left(equalTo: separatorView.rightAnchor) .right(equalTo: self.safeAreaLayoutGuide.rightAnchor) } @@ -89,7 +89,7 @@ final class HomeView: UIView { addSubview(achievementCollectionView) achievementCollectionView.atl .width(equalTo: self.widthAnchor) - .top(equalTo: categoryCollectionView.bottomAnchor) + .top(equalTo: categoryCollectionView.bottomAnchor, constant: 10) .bottom(equalTo: self.bottomAnchor) } } From 8a56e3063b0897c2a008959493999bf10ec0c959 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:44:26 +0900 Subject: [PATCH 016/188] =?UTF-8?q?[BE]=20feat:=20UsersRoleEntity=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 권한을 enum 형태로 수정 --- BE/src/users/entities/role.entity.ts | 10 -------- BE/src/users/entities/users-role.entity.ts | 27 ++++++++++++++-------- 2 files changed, 18 insertions(+), 19 deletions(-) delete mode 100644 BE/src/users/entities/role.entity.ts diff --git a/BE/src/users/entities/role.entity.ts b/BE/src/users/entities/role.entity.ts deleted file mode 100644 index d2da225d..00000000 --- a/BE/src/users/entities/role.entity.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity({ name: 'role' }) -export class RoleEntity { - @PrimaryGeneratedColumn({ type: 'int' }) - id: number; - - @Column({ type: 'varchar', length: 20, nullable: false }) - role: string; -} diff --git a/BE/src/users/entities/users-role.entity.ts b/BE/src/users/entities/users-role.entity.ts index 6edb42c7..3b0494fd 100644 --- a/BE/src/users/entities/users-role.entity.ts +++ b/BE/src/users/entities/users-role.entity.ts @@ -1,21 +1,30 @@ -import { Entity, JoinColumn, ManyToOne } from 'typeorm'; -import { PrimaryColumn } from 'typeorm'; +import { Entity, JoinColumn, ManyToOne, PrimaryColumn, Unique } from 'typeorm'; import { UserEntity } from './user.entity'; -import { RoleEntity } from './role.entity'; +import { UserRole } from '../domain/user-role'; @Entity({ name: 'user_role' }) export class UsersRoleEntity { @PrimaryColumn({ type: 'bigint', nullable: false }) userId: number; - @PrimaryColumn({ type: 'int', nullable: false }) - roleId: number; - @ManyToOne(() => UserEntity) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; - @ManyToOne(() => RoleEntity) - @JoinColumn({ name: 'role_id', referencedColumnName: 'id' }) - role: RoleEntity; + @PrimaryColumn({ + type: 'simple-enum', + enum: UserRole, + default: UserRole.MEMBER, + }) + role: UserRole = UserRole.MEMBER; + + constructor(user: UserEntity, role: UserRole) { + this.user = user; + this.userId = user?.id; + this.role = role; + } + + toModel(): UserRole { + return this.role; + } } From 06fa47124c2c481acdc817e14c2d086f9982c495 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:51:30 +0900 Subject: [PATCH 017/188] =?UTF-8?q?[BE]=20feat:=20User=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 권한 정보를 가지고 있도록 수정 --- BE/src/users/domain/user.domain.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BE/src/users/domain/user.domain.ts b/BE/src/users/domain/user.domain.ts index c679f822..5ac751de 100644 --- a/BE/src/users/domain/user.domain.ts +++ b/BE/src/users/domain/user.domain.ts @@ -1,3 +1,5 @@ +import { UserRole } from './user-role'; + export class User { id: number; @@ -7,6 +9,8 @@ export class User { userIdentifier: string; + roles: UserRole[] = [UserRole.MEMBER]; + static from(userIdentifier: string) { const user = new User(); user.userIdentifier = userIdentifier; From 93bdd0ff38369001ef771b5c2628183c44aad924 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:52:08 +0900 Subject: [PATCH 018/188] =?UTF-8?q?[BE]=20feat:=20UserEntity=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserRoleEntity를 OneToMany로 연결 --- BE/src/users/entities/user.entity.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/BE/src/users/entities/user.entity.ts b/BE/src/users/entities/user.entity.ts index ff89a743..6c689082 100644 --- a/BE/src/users/entities/user.entity.ts +++ b/BE/src/users/entities/user.entity.ts @@ -1,6 +1,7 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { BaseTimeEntity } from '../../common/entities/base.entity'; import { User } from '../domain/user.domain'; +import { UsersRoleEntity } from './users-role.entity'; @Entity({ name: 'user' }) export class UserEntity extends BaseTimeEntity { @@ -16,12 +17,20 @@ export class UserEntity extends BaseTimeEntity { @Column({ type: 'varchar', length: 100, nullable: false }) userIdentifier: string; + @OneToMany(() => UsersRoleEntity, (userRole) => userRole.user, { + cascade: ['insert'], + }) + userRoles: UsersRoleEntity[]; + static from(user: User) { const userEntity = new UserEntity(); userEntity.id = user.id; userEntity.userIdentifier = user.userIdentifier; userEntity.avatarUrl = user.avatarUrl; userEntity.userCode = user.userCode; + userEntity.userRoles = user.roles?.map((role) => { + return new UsersRoleEntity(userEntity, role); + }); return userEntity; } @@ -32,6 +41,7 @@ export class UserEntity extends BaseTimeEntity { user.userIdentifier = this.userIdentifier; user.userCode = this.userCode; user.id = this.id; + user.roles = this.userRoles?.map((userRole) => userRole.role); return user; } } From 857f4f00c042d43598eec99e2c2814779924797f Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:53:20 +0900 Subject: [PATCH 019/188] =?UTF-8?q?[BE]=20chore:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20import=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/users/entities/users-role.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BE/src/users/entities/users-role.entity.ts b/BE/src/users/entities/users-role.entity.ts index 3b0494fd..9b88db59 100644 --- a/BE/src/users/entities/users-role.entity.ts +++ b/BE/src/users/entities/users-role.entity.ts @@ -1,4 +1,4 @@ -import { Entity, JoinColumn, ManyToOne, PrimaryColumn, Unique } from 'typeorm'; +import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; import { UserEntity } from './user.entity'; import { UserRole } from '../domain/user-role'; From 075782ce079afa8f0eaccf1a6396777dd7ab22c7 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:55:47 +0900 Subject: [PATCH 020/188] =?UTF-8?q?[BE]=20feat:=20UsersRoleRepository=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User에 새로운 UserRole을 부여하는 saveUserRole - User에 대한 UserRole을 조회하는 findUserRole --- .../users/entities/users-role.repository.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 BE/src/users/entities/users-role.repository.ts diff --git a/BE/src/users/entities/users-role.repository.ts b/BE/src/users/entities/users-role.repository.ts new file mode 100644 index 00000000..c9d3fe0f --- /dev/null +++ b/BE/src/users/entities/users-role.repository.ts @@ -0,0 +1,24 @@ +import { CustomRepository } from '../../config/typeorm/custom-repository.decorator'; +import { TransactionalRepository } from '../../config/transaction-manager/transactional-repository'; +import { UsersRoleEntity } from './users-role.entity'; +import { UserRole } from '../domain/user-role'; +import { User } from '../domain/user.domain'; +import { UserEntity } from './user.entity'; + +@CustomRepository(UsersRoleEntity) +export class UsersRoleRepository extends TransactionalRepository { + async saveUserRole(user: User, userRole: UserRole): Promise { + const userEntity = UserEntity.from(user); + const userRoleEntity = new UsersRoleEntity(userEntity, userRole); + const saved = await this.repository.save(userRoleEntity); + return saved.toModel(); + } + + async findUserRole(user: User): Promise { + const userEntity = UserEntity.from(user); + const usersRoleEntities = await this.repository.find({ + where: { user: { id: userEntity.id } }, + }); + return usersRoleEntities?.map((entity) => entity.toModel()); + } +} From 3791a69fd44a130cb8df32f73e49d55bf0722156 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:56:02 +0900 Subject: [PATCH 021/188] =?UTF-8?q?[BE]=20feat:=20UserRoleRepository=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/users-role.repository.spec.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 BE/src/users/entities/users-role.repository.spec.ts diff --git a/BE/src/users/entities/users-role.repository.spec.ts b/BE/src/users/entities/users-role.repository.spec.ts new file mode 100644 index 00000000..f45c90e2 --- /dev/null +++ b/BE/src/users/entities/users-role.repository.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { configServiceModuleOptions } from '../../config/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { typeOrmModuleOptions } from '../../config/typeorm'; +import { CustomTypeOrmModule } from '../../config/typeorm/custom-typeorm.module'; +import { UsersRoleRepository } from './users-role.repository'; +import { DataSource } from 'typeorm'; +import { UsersTestModule } from '../../../test/user/users-test.module'; +import { UsersFixture } from '../../../test/user/users-fixture'; +import { UserRole } from '../domain/user-role'; +import { transactionTest } from '../../../test/common/transaction-test'; + +describe('UsersRoleRepository test', () => { + let usersRoleRepository: UsersRoleRepository; + let usersFixture: UsersFixture; + let dataSource: DataSource; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(configServiceModuleOptions), + TypeOrmModule.forRootAsync(typeOrmModuleOptions), + UsersTestModule, + CustomTypeOrmModule.forCustomRepository([UsersRoleRepository]), + ], + }).compile(); + + usersRoleRepository = module.get(UsersRoleRepository); + usersFixture = module.get(UsersFixture); + dataSource = module.get(DataSource); + }); + + afterAll(async () => { + await dataSource.destroy(); + }); + + test('saveUserRole로 user의 UserRole을 추가할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser(1); + + // when + const savedUserRole = await usersRoleRepository.saveUserRole( + user, + UserRole.ADMIN, + ); + + // then + expect(savedUserRole).toBe(UserRole.ADMIN); + }); + }); + + test('findUserRole로 user의 UserRole을 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser(1); + await usersRoleRepository.saveUserRole(user, UserRole.ADMIN); + + // when + const userRoles = await usersRoleRepository.findUserRole(user); + + // then + expect(userRoles.length).toBe(2); + expect(userRoles).toContain(UserRole.ADMIN); + expect(userRoles).toContain(UserRole.MEMBER); + }); + }); + + test('saveUserRole로 user에게 이미 존재하는 Role을 추가할 수 없다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser(1); + await usersRoleRepository.saveUserRole(user, UserRole.ADMIN); + + // when + await usersRoleRepository.saveUserRole(user, UserRole.ADMIN); + const userRoles = await usersRoleRepository.findUserRole(user); + + // then + expect(userRoles.length).toBe(2); + expect(userRoles).toContain(UserRole.MEMBER); + expect(userRoles).toContain(UserRole.ADMIN); + }); + }); +}); From e2a1174e58ba182a804139453126eaa15c341df9 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:56:26 +0900 Subject: [PATCH 022/188] =?UTF-8?q?[BE]=20feat:=20UserRole=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enum 타입으로 생성 --- BE/src/users/domain/user-role.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 BE/src/users/domain/user-role.ts diff --git a/BE/src/users/domain/user-role.ts b/BE/src/users/domain/user-role.ts new file mode 100644 index 00000000..3243fded --- /dev/null +++ b/BE/src/users/domain/user-role.ts @@ -0,0 +1,4 @@ +export enum UserRole { + ADMIN = 'ADMIN', + MEMBER = 'MEMBER', +} From 91dcf8d3ea12b523fcb54595d8f6e41cf8652b7b Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 21:58:34 +0900 Subject: [PATCH 023/188] =?UTF-8?q?[BE]=20feat:=20AdminRepository=EC=97=90?= =?UTF-8?q?=20findPendingAdminByEmail=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저에 대해 권한 정보를 함께 조인해오도록 생성 --- BE/src/admin/entities/admin.repository.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/BE/src/admin/entities/admin.repository.ts b/BE/src/admin/entities/admin.repository.ts index 7687b6cc..4847c727 100644 --- a/BE/src/admin/entities/admin.repository.ts +++ b/BE/src/admin/entities/admin.repository.ts @@ -22,10 +22,18 @@ export class AdminRepository extends TransactionalRepository { return adminEntity?.toModel(); } + async findPendingAdminByEmail(email: string): Promise { + const adminEntity = await this.repository.findOne({ + where: { email: email, status: AdminStatus.PENDING }, + relations: ['user', 'user.userRoles'], + }); + return adminEntity?.toModel(); + } + async findActiveAdminByEmail(email: string): Promise { const adminEntity = await this.repository.findOne({ where: { email: email, status: AdminStatus.ACTIVE }, - relations: ['user'], + relations: ['user', 'user.userRoles'], }); return adminEntity?.toModel(); } From ac4ee5f57734ba7c32f73992aa375c97bd3ef29a Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:00:41 +0900 Subject: [PATCH 024/188] =?UTF-8?q?[BE]=20feat:=20admin=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EC=97=90=20accepted=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin으로 수락되었을 때 호출하는 메서드 --- BE/src/admin/domain/admin.domain.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/BE/src/admin/domain/admin.domain.ts b/BE/src/admin/domain/admin.domain.ts index c68ebb1f..262f61a0 100644 --- a/BE/src/admin/domain/admin.domain.ts +++ b/BE/src/admin/domain/admin.domain.ts @@ -1,13 +1,19 @@ import { User } from '../../users/domain/user.domain'; import { AdminStatus } from './admin-status'; import { PasswordEncoder } from '../application/password-encoder'; +import { UserRole } from '../../users/domain/user-role'; export class Admin { - user: User; + readonly user: User; email: string; password: string; status: AdminStatus = AdminStatus.PENDING; + accepted() { + this.status = AdminStatus.ACTIVE; + this.user.roles = [...this.user.roles, UserRole.ADMIN]; + } + async register(passwordEncoder: PasswordEncoder) { this.password = await passwordEncoder.encode(this.password); } From a673c2ee113a62fb56bfccc6eb71d0cd6a855a8b Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:01:06 +0900 Subject: [PATCH 025/188] =?UTF-8?q?[BE]=20feat:=20admin=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/admin/domain/admin.domain.spec.ts | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 BE/src/admin/domain/admin.domain.spec.ts diff --git a/BE/src/admin/domain/admin.domain.spec.ts b/BE/src/admin/domain/admin.domain.spec.ts new file mode 100644 index 00000000..3c6034bb --- /dev/null +++ b/BE/src/admin/domain/admin.domain.spec.ts @@ -0,0 +1,72 @@ +import { User } from '../../users/domain/user.domain'; +import { Admin } from './admin.domain'; +import { AdminStatus } from './admin-status'; +import { BcryptPasswordEncoder } from '../application/bcrypt-password-encoder'; +import { ConfigService } from '@nestjs/config'; +import { UserRole } from '../../users/domain/user-role'; + +describe('Admin 도메인 객체 Test', () => { + const bcryptPasswordEncoder = new BcryptPasswordEncoder( + new ConfigService({ + BCRYPT_SALT: 1, + }), + ); + + it('Admin 도메인 객체를 생성할 수 있다.', () => { + // given + const user = User.from('123'); + user.assignUserCode('ABCEAQ2'); + + // when + const admin = new Admin(user, 'abc123@abc.com', '1234'); + + // then + expect(admin).toBeDefined(); + expect(admin.email).toBe('abc123@abc.com'); + expect(admin.password).toBe('1234'); + expect(admin.status).toBe(AdminStatus.PENDING); + expect(admin.user).toStrictEqual(user); + }); + + it('register를 이용해 인코딩된 password로 재설정할 수 있다.', async () => { + // given + const user = User.from('123'); + user.assignUserCode('ABCEAQ2'); + const admin = new Admin(user, 'abc@abc.com', '1234'); + + // when + await admin.register(bcryptPasswordEncoder); + + // then + expect(admin.password).not.toBe('1234'); + }); + + it('auth는 비밀번호를 검증할 수 있다.', async () => { + // given + const user = User.from('123'); + user.assignUserCode('ABCEAQ2'); + const admin = new Admin(user, 'abc@abc.com', '1234'); + await admin.register(bcryptPasswordEncoder); + + // when + const authResult = await admin.auth('1234', bcryptPasswordEncoder); + + // then + expect(admin.password).not.toBe('1234'); + expect(authResult).toBe(true); + }); + + it('accepted는 어드민을 활성화 상태로 변경한다.', () => { + // given + const user = User.from('123'); + user.assignUserCode('ABCEAQ2'); + const admin = new Admin(user, 'abc@abc.com', '1234'); + + // when + admin.accepted(); + + // then + expect(admin.status).toBe(AdminStatus.ACTIVE); + expect(admin.user.roles).toContain(UserRole.ADMIN); + }); +}); From 9cbc5d99e7d1d6b1e0e6b14e3e9c7b561aac0b5e Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:02:20 +0900 Subject: [PATCH 026/188] =?UTF-8?q?[BE]=20feat:=20AdminTestModule=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserTestModule에서 UserFixture 사용할 수 있도록 - adminFixure 생성 --- BE/test/admin/admin-fixture.ts | 41 ++++++++++++++++++++++++++++++ BE/test/admin/admin-test.module.ts | 18 +++++++++++++ BE/test/user/users-test.module.ts | 1 + 3 files changed, 60 insertions(+) create mode 100644 BE/test/admin/admin-fixture.ts create mode 100644 BE/test/admin/admin-test.module.ts diff --git a/BE/test/admin/admin-fixture.ts b/BE/test/admin/admin-fixture.ts new file mode 100644 index 00000000..997046aa --- /dev/null +++ b/BE/test/admin/admin-fixture.ts @@ -0,0 +1,41 @@ +import { User } from '../../src/users/domain/user.domain'; +import { Injectable } from '@nestjs/common'; +import { UsersFixture } from '../user/users-fixture'; +import { AdminRepository } from '../../src/admin/entities/admin.repository'; +import { Admin } from '../../src/admin/domain/admin.domain'; +import { UserRole } from '../../src/users/domain/user-role'; +import { UserRepository } from '../../src/users/entities/user.repository'; +import { AdminStatus } from '../../src/admin/domain/admin-status'; + +@Injectable() +export class AdminFixture { + static id = 0; + + constructor( + private readonly userRepository: UserRepository, + private readonly adminRepository: AdminRepository, + ) {} + + async getAdmin( + id: number | string, + email?: string, + password?: string, + ): Promise { + const user = UsersFixture.user(id); + user.roles.push(UserRole.ADMIN); + const adminRoleUser = await this.userRepository.saveUser(user); + + const admin = AdminFixture.admin(adminRoleUser, email, password); + admin.status = AdminStatus.ACTIVE; + + return this.adminRepository.saveAdmin(admin); + } + + static admin(user: User, email: string, password: string): Admin { + return new Admin( + user, + email || `email${++AdminFixture.id}`, + password || `password${AdminFixture.id}`, + ); + } +} diff --git a/BE/test/admin/admin-test.module.ts b/BE/test/admin/admin-test.module.ts new file mode 100644 index 00000000..584da580 --- /dev/null +++ b/BE/test/admin/admin-test.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { CustomTypeOrmModule } from '../../src/config/typeorm/custom-typeorm.module'; +import { UserRepository } from '../../src/users/entities/user.repository'; +import { AdminFixture } from './admin-fixture'; +import { UsersTestModule } from '../user/users-test.module'; +import { AdminModule } from '../../src/admin/admin.module'; +import { AdminRepository } from '../../src/admin/entities/admin.repository'; + +@Module({ + imports: [ + CustomTypeOrmModule.forCustomRepository([AdminRepository, UserRepository]), + UsersTestModule, + AdminModule, + ], + providers: [AdminFixture], + exports: [AdminFixture], +}) +export class AdminTestModule {} diff --git a/BE/test/user/users-test.module.ts b/BE/test/user/users-test.module.ts index 795c5269..4b0f75e6 100644 --- a/BE/test/user/users-test.module.ts +++ b/BE/test/user/users-test.module.ts @@ -10,5 +10,6 @@ import { UsersModule } from '../../src/users/users.module'; UsersModule, ], providers: [UsersFixture], + exports: [UsersFixture], }) export class UsersTestModule {} From 7b2f9bd4afd14ac89279e419bb1e6f3e1f027fd9 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:07:52 +0900 Subject: [PATCH 027/188] =?UTF-8?q?[BE]=20feat:=20userService=EC=97=90=20g?= =?UTF-8?q?etUserByUserCodeWithRoles=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 권한 정보가 포함된 유저 조회 - 관련 테스트 코드 추가 --- BE/src/users/application/users.service.spec.ts | 16 ++++++++++++++++ BE/src/users/application/users.service.ts | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/BE/src/users/application/users.service.spec.ts b/BE/src/users/application/users.service.spec.ts index 97a8c804..38907786 100644 --- a/BE/src/users/application/users.service.spec.ts +++ b/BE/src/users/application/users.service.spec.ts @@ -7,6 +7,7 @@ import { typeOrmModuleOptions } from '../../config/typeorm'; import { ConfigModule } from '@nestjs/config'; import { configServiceModuleOptions } from '../../config/config'; import { UsersModule } from '../users.module'; +import { UserRole } from '../domain/user-role'; describe('UsersService Test', () => { let userService: UsersService; @@ -42,4 +43,19 @@ describe('UsersService Test', () => { expect(findOne.userCode).toBe('A1B2C1D'); expect(findOne.userIdentifier).toBe('userIdentifier'); }); + + test('getUserByUserCodeWithRoles는 권한 정보가 포함된 유저를 userCode로 조회할 수 있다.', async () => { + const user = User.from('userIdentifier'); + user.assignUserCode('A1B2C1D'); + await userRepository.saveUser(user); + + // when + const findOne = await userService.getUserByUserCodeWithRoles('A1B2C1D'); + + // then + expect(findOne.userCode).toBe('A1B2C1D'); + expect(findOne.userIdentifier).toBe('userIdentifier'); + expect(findOne.roles).toHaveLength(1); + expect(findOne.roles).toContain(UserRole.MEMBER); + }); }); diff --git a/BE/src/users/application/users.service.ts b/BE/src/users/application/users.service.ts index 90647ab0..56389d75 100644 --- a/BE/src/users/application/users.service.ts +++ b/BE/src/users/application/users.service.ts @@ -11,8 +11,13 @@ export class UsersService { private readonly usersRepository: UserRepository, ) {} - @Transactional() + @Transactional({ readonly: true }) async findOneByUserCode(userCode: string): Promise { return await this.usersRepository.findOneByUserCode(userCode); } + + @Transactional({ readonly: true }) + async getUserByUserCodeWithRoles(userCode: string): Promise { + return await this.usersRepository.findOneByUserCodeWithRoles(userCode); + } } From c662225bb391129ea51683dea7cd992445ddf644 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:16:10 +0900 Subject: [PATCH 028/188] =?UTF-8?q?[BE]=20feat:=20userRepository=EC=97=90?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findOneByUserIdentifierWithRoles 생성 - findOneByUserCodeWithRoles 생성 - 관련 테스트 추가 --- BE/src/users/entities/user.repository.spec.ts | 50 +++++++++++++++++++ BE/src/users/entities/user.repository.ts | 23 ++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/BE/src/users/entities/user.repository.spec.ts b/BE/src/users/entities/user.repository.spec.ts index 09c46ed9..8eb912a2 100644 --- a/BE/src/users/entities/user.repository.spec.ts +++ b/BE/src/users/entities/user.repository.spec.ts @@ -8,6 +8,7 @@ import { User } from '../domain/user.domain'; import { configServiceModuleOptions } from '../../config/config'; import { DataSource } from 'typeorm'; import { transactionTest } from '../../../test/common/transaction-test'; +import { UserRole } from '../domain/user-role'; describe('UserRepository test', () => { let usersRepository: UserRepository; @@ -78,4 +79,53 @@ describe('UserRepository test', () => { expect(findOne.userIdentifier).toBe('userIdentifier'); }); }); + + test('findOneByUserIdentifierWithRoles는 권한정보를 포함하여 user를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = User.from('userIdentifier'); + user.assignUserCode('A1B2C1D'); + await usersRepository.saveUser(user); + + // when + const findOne = + await usersRepository.findOneByUserIdentifierWithRoles( + 'userIdentifier', + ); + + // then + expect(findOne.userCode).toBe('A1B2C1D'); + expect(findOne.roles.length).toBe(1); + expect(findOne.roles[0]).toBe(UserRole.MEMBER); + }); + }); + + test('findOneByUserCodeWithRoles 빈값에 대해 빈 값을 반환한다.', async () => { + await transactionTest(dataSource, async () => { + // when + const findOne = + await usersRepository.findOneByUserIdentifierWithRoles(undefined); + + // then + expect(findOne).toBeUndefined(); + }); + }); + + test('findOneByUserCodeWithRoles는 권한정보를 포함하여 user를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = User.from('userIdentifier'); + user.assignUserCode('A1B2C1D'); + await usersRepository.saveUser(user); + + // when + const findOne = + await usersRepository.findOneByUserCodeWithRoles('A1B2C1D'); + + // then + expect(findOne.userCode).toBe('A1B2C1D'); + expect(findOne.roles.length).toBe(1); + expect(findOne.roles).toContain(UserRole.MEMBER); + }); + }); }); diff --git a/BE/src/users/entities/user.repository.ts b/BE/src/users/entities/user.repository.ts index 104770c9..2c2e605f 100644 --- a/BE/src/users/entities/user.repository.ts +++ b/BE/src/users/entities/user.repository.ts @@ -6,8 +6,8 @@ import { TransactionalRepository } from '../../config/transaction-manager/transa @CustomRepository(UserEntity) export class UserRepository extends TransactionalRepository { async findOneByUserIdentifier(userIdentifier: string): Promise { - const userEntity = await this.repository.findOneBy({ - userIdentifier: userIdentifier, + const userEntity = await this.repository.findOne({ + where: { userIdentifier: userIdentifier }, }); return userEntity?.toModel(); } @@ -24,7 +24,26 @@ export class UserRepository extends TransactionalRepository { const saved = await this.repository.save(userEntity); return saved.toModel(); } + async existByUserCode(userCode: string) { return await this.repository.exist({ where: { userCode: userCode } }); } + + async findOneByUserIdentifierWithRoles( + userIdentifier: string, + ): Promise { + const userEntity = await this.repository.findOne({ + where: { userIdentifier: userIdentifier }, + relations: ['userRoles'], + }); + return userEntity?.toModel(); + } + + async findOneByUserCodeWithRoles(userCode: string): Promise { + const userEntity = await this.repository.findOne({ + where: { userCode: userCode }, + relations: ['userRoles'], + }); + return userEntity?.toModel(); + } } From 2e8e6bfc0a0ae48eb8061d0587ca46ad4ece9980 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:17:10 +0900 Subject: [PATCH 029/188] =?UTF-8?q?[BE]=20refactor:=20UnexpectedExceptionF?= =?UTF-8?q?ilter=EC=97=90=EC=84=9C=20=EB=A1=9C=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/common/filter/unexpected-exception.filter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/BE/src/common/filter/unexpected-exception.filter.ts b/BE/src/common/filter/unexpected-exception.filter.ts index 30b01fca..3c29c509 100644 --- a/BE/src/common/filter/unexpected-exception.filter.ts +++ b/BE/src/common/filter/unexpected-exception.filter.ts @@ -1,12 +1,15 @@ -import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; import { Response } from 'express'; import { ApiData } from '../api/api-data'; @Catch() export class UnexpectedExceptionFilter implements ExceptionFilter { + logger = new Logger(UnexpectedExceptionFilter.name); + catch(exception: unknown, host: ArgumentsHost): any { const ctx = host.switchToHttp(); const response = ctx.getResponse(); + this.logger.error(exception); response.status(500).json(ApiData.error('Internal Server Error')); } From d85f3d58eebbcedbcf4634bd91aeddadfc177c17 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:19:53 +0900 Subject: [PATCH 030/188] =?UTF-8?q?[BE]=20fix:=20AdminEntity=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email에 unique 제약 조건 - AdminStatus를 기본키 조합에서 삭제 --- BE/src/admin/entities/admin.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BE/src/admin/entities/admin.entity.ts b/BE/src/admin/entities/admin.entity.ts index f5e7176a..ecf8004f 100644 --- a/BE/src/admin/entities/admin.entity.ts +++ b/BE/src/admin/entities/admin.entity.ts @@ -12,13 +12,13 @@ export class AdminEntity { @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; - @Column({ type: 'varchar', length: 100, nullable: false }) + @Column({ type: 'varchar', length: 100, nullable: false, unique: true }) email: string; @Column({ type: 'varchar', length: 100 }) password: string; - @PrimaryColumn({ + @Column({ type: 'simple-enum', enum: AdminStatus, default: AdminStatus.PENDING, From 0ded7d369361a852df73bcb31145b0d4f8d641fe Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:20:50 +0900 Subject: [PATCH 031/188] =?UTF-8?q?[BE]=20refactor:=20UserAlreadyRegistere?= =?UTF-8?q?dAdminException=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ered-admin.ts => user-already-registered-admin.exception.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename BE/src/admin/exception/{user-already-registered-admin.ts => user-already-registered-admin.exception.ts} (74%) diff --git a/BE/src/admin/exception/user-already-registered-admin.ts b/BE/src/admin/exception/user-already-registered-admin.exception.ts similarity index 74% rename from BE/src/admin/exception/user-already-registered-admin.ts rename to BE/src/admin/exception/user-already-registered-admin.exception.ts index e52f3376..5b971a45 100644 --- a/BE/src/admin/exception/user-already-registered-admin.ts +++ b/BE/src/admin/exception/user-already-registered-admin.exception.ts @@ -1,7 +1,7 @@ import { MotimateException } from '../../common/exception/motimate.excpetion'; import { ERROR_INFO } from '../../common/exception/error-code'; -export class UserAlreadyRegisteredAdmin extends MotimateException { +export class UserAlreadyRegisteredAdminException extends MotimateException { constructor() { super(ERROR_INFO.USER_ALREADY_REGISTERED_ADMIN); } From 92a77601337cf34709448436e1b2773540954c19 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:21:55 +0900 Subject: [PATCH 032/188] =?UTF-8?q?[BE]=20feat:=20UserNotAdminPendingStatu?= =?UTF-8?q?sException=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저가 어드민 등록 PENDING 상태가 아닐 때 사용할 예외 --- .../exception/user-not-admin-pending-status.exception.ts | 8 ++++++++ BE/src/common/exception/error-code.ts | 4 ++++ 2 files changed, 12 insertions(+) create mode 100644 BE/src/admin/exception/user-not-admin-pending-status.exception.ts diff --git a/BE/src/admin/exception/user-not-admin-pending-status.exception.ts b/BE/src/admin/exception/user-not-admin-pending-status.exception.ts new file mode 100644 index 00000000..004ffb23 --- /dev/null +++ b/BE/src/admin/exception/user-not-admin-pending-status.exception.ts @@ -0,0 +1,8 @@ +import { MotimateException } from '../../common/exception/motimate.excpetion'; +import { ERROR_INFO } from '../../common/exception/error-code'; + +export class UserNotAdminPendingStatusException extends MotimateException { + constructor() { + super(ERROR_INFO.USER_NOT_ADMIN_PENDING_STATUS); + } +} diff --git a/BE/src/common/exception/error-code.ts b/BE/src/common/exception/error-code.ts index de81a70b..d7693ba6 100644 --- a/BE/src/common/exception/error-code.ts +++ b/BE/src/common/exception/error-code.ts @@ -31,4 +31,8 @@ export const ERROR_INFO = { statusCode: 400, message: '잘못된 비밀번호입니다.', }, + USER_NOT_ADMIN_PENDING_STATUS: { + statusCode: 400, + message: '관리자 승인 대기중인 사용자가 아닙니다.', + }, } as const; From caa5c11946152abe4eaebd4d394267ecdbc56c2e Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:24:29 +0900 Subject: [PATCH 033/188] =?UTF-8?q?[BE]=20feat:=20AuthService=EC=9D=98=20a?= =?UTF-8?q?ppleLogin=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jwt에 권한 정보가 포함되도록 수정 --- BE/src/auth/application/auth.service.ts | 12 ++++++++---- BE/src/auth/index.ts | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/BE/src/auth/application/auth.service.ts b/BE/src/auth/application/auth.service.ts index c5bdf46c..bc07e654 100644 --- a/BE/src/auth/application/auth.service.ts +++ b/BE/src/auth/application/auth.service.ts @@ -6,7 +6,7 @@ import { AppleLoginRequest } from '../dto/apple-login-request.dto'; import { UserCodeGenerator } from './user-code-generator'; import { Transactional } from '../../config/transaction-manager'; import { JwtUtils } from './jwt-utils'; -import { JwtClaim } from '../index'; +import { JwtClaim, JwtRolePayloads } from '../index'; import { AppleLoginResponse } from '../dto/apple-login-response.dto'; import { UserDto } from '../../users/dto/user.dto'; import { RefreshAuthRequestDto } from '../dto/refresh-auth-request.dto'; @@ -29,11 +29,15 @@ export class AuthService { appleLoginRequest.identityToken, ); const user = - (await this.usersRepository.findOneByUserIdentifier(userIdentifier)) || - (await this.registerUser(userIdentifier)); + (await this.usersRepository.findOneByUserIdentifierWithRoles( + userIdentifier, + )) || (await this.registerUser(userIdentifier)); const now = new Date(); - const claim: JwtClaim = { userCode: user.userCode }; + const claim: JwtRolePayloads = { + userCode: user.userCode, + roles: user.roles, + }; const accessToken = this.jwtUtils.createToken(claim, now); const refreshToken = this.jwtUtils.createRefreshToken(claim, now); return new AppleLoginResponse( diff --git a/BE/src/auth/index.ts b/BE/src/auth/index.ts index c5f7b6e8..65eb27f9 100644 --- a/BE/src/auth/index.ts +++ b/BE/src/auth/index.ts @@ -13,4 +13,8 @@ export interface PublicKeysResponse { export interface JwtClaim { userCode: string; } +export interface JwtRolePayloads extends JwtClaim { + roles: string[]; +} + export type Payload = { iat: number; exp: number } & JwtClaim; From 5330496580fb47a4e7b7dd78a2ad9359bf44d018 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:26:34 +0900 Subject: [PATCH 034/188] =?UTF-8?q?[BE]=20feat:=20AdminService=EC=9D=98=20?= =?UTF-8?q?loginAdmin=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jwt에 권한 정보가 포함되도록 수정 --- BE/src/admin/application/admin.service.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/BE/src/admin/application/admin.service.ts b/BE/src/admin/application/admin.service.ts index 2ed883c6..4d3d4911 100644 --- a/BE/src/admin/application/admin.service.ts +++ b/BE/src/admin/application/admin.service.ts @@ -4,18 +4,22 @@ import { User } from '../../users/domain/user.domain'; import { AdminRepository } from '../entities/admin.repository'; import { Admin } from '../domain/admin.domain'; import { Transactional } from '../../config/transaction-manager'; -import { UserAlreadyRegisteredAdmin } from '../exception/user-already-registered-admin'; +import { UserAlreadyRegisteredAdminException } from '../exception/user-already-registered-admin.exception'; import { PasswordEncoder } from './password-encoder'; import { AdminRegister } from '../dto/admin-register'; -import { AdminInvalidPasswordException } from '../exception/admin-invalid-password'; +import { AdminInvalidPasswordException } from '../exception/admin-invalid-password.exception'; import { JwtUtils } from '../../auth/application/jwt-utils'; -import { JwtClaim } from '../../auth'; +import { JwtRolePayloads } from '../../auth'; +import { UserNotAdminPendingStatusException } from '../exception/user-not-admin-pending-status.exception'; +import { UsersRoleRepository } from '../../users/entities/users-role.repository'; +import { UserRole } from '../../users/domain/user-role'; @Injectable() export class AdminService { constructor( private readonly adminRepository: AdminRepository, private readonly passwordEncoder: PasswordEncoder, + private readonly userRoleRepository: UsersRoleRepository, private readonly jwtUtils: JwtUtils, ) {} @@ -24,9 +28,13 @@ export class AdminService { const admin = await this.adminRepository.findActiveAdminByEmail( loginRequest.email, ); + if (!admin) throw new UserNotAdminPendingStatusException(); if (!(await admin.auth(loginRequest.password, this.passwordEncoder))) throw new AdminInvalidPasswordException(); - const claim: JwtClaim = { userCode: admin.user.userCode }; + const claim: JwtRolePayloads = { + userCode: admin.user.userCode, + roles: admin.user.roles, + }; return this.jwtUtils.createToken(claim, new Date()); } @@ -36,7 +44,7 @@ export class AdminService { user: User, ): Promise { const admin = await this.adminRepository.getUserAdmin(user); - if (admin) throw new UserAlreadyRegisteredAdmin(); + if (admin) throw new UserAlreadyRegisteredAdminException(); const registerAdmin = adminRegister.toModel(user); await registerAdmin.register(this.passwordEncoder); From 4befa22c7155ba5a4f6f2a1f63c7c854e3a3afe2 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:27:27 +0900 Subject: [PATCH 035/188] =?UTF-8?q?[BE]=20feat:=20AdminService=EC=9D=98=20?= =?UTF-8?q?acceptAdminRegister=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어드민 요청을 수락하는 함수 - 관련 테스트 코드 --- .../admin/application/admin.service.spec.ts | 64 +++++++++++++++++-- BE/src/admin/application/admin.service.ts | 10 +++ ...ts => admin-invalid-password.exception.ts} | 0 3 files changed, 70 insertions(+), 4 deletions(-) rename BE/src/admin/exception/{admin-invalid-password.ts => admin-invalid-password.exception.ts} (100%) diff --git a/BE/src/admin/application/admin.service.spec.ts b/BE/src/admin/application/admin.service.spec.ts index accb2da7..c3206f85 100644 --- a/BE/src/admin/application/admin.service.spec.ts +++ b/BE/src/admin/application/admin.service.spec.ts @@ -16,14 +16,18 @@ import { AdminRepository } from '../entities/admin.repository'; import { AdminLogin } from '../dto/admin-login'; import { AdminInvalidPasswordException } from '../exception/admin-invalid-password.exception'; import { UserAlreadyRegisteredAdminException } from '../exception/user-already-registered-admin.exception'; -import { UsersTestModule } from '../../../test/user/users-test.module'; import { UsersFixture } from '../../../test/user/users-fixture'; +import { AdminTestModule } from '../../../test/admin/admin-test.module'; +import { AdminFixture } from '../../../test/admin/admin-fixture'; +import { UserNotAdminPendingStatusException } from '../exception/user-not-admin-pending-status.exception'; +import { UserRole } from '../../users/domain/user-role'; describe('AdminService Test', () => { let adminService: AdminService; let passwordEncoder: PasswordEncoder; let dataSource: DataSource; let usersFixture: UsersFixture; + let adminFixture: AdminFixture; let adminRepository: AdminRepository; beforeAll(async () => { @@ -31,12 +35,13 @@ describe('AdminService Test', () => { imports: [ TypeOrmModule.forRootAsync(typeOrmModuleOptions), AdminModule, - UsersTestModule, + AdminTestModule, ConfigModule.forRoot(configServiceModuleOptions), ], }).compile(); usersFixture = app.get(UsersFixture); + adminFixture = app.get(AdminFixture); adminRepository = app.get(AdminRepository); adminService = app.get(AdminService); passwordEncoder = app.get(PasswordEncoder); @@ -104,14 +109,31 @@ describe('AdminService Test', () => { }); }); + it('loginAdmin은 존재 하지 않는 email의 Admin에 대해 UserNotAdminPendingStatusException를 발생시킨다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const admin = new Admin(user, 'abc124@abc.com', '1234'); + admin.status = AdminStatus.ACTIVE; + await adminRepository.saveAdmin(admin); + const adminLogin = new AdminLogin('abc124@abc.com', '12345'); + + // when + // then + await expect(adminService.loginAdmin(adminLogin)).rejects.toThrow( + AdminInvalidPasswordException, + ); + }); + }); + it('loginAdmin은 ACTIVE 상태의 어드민이 올바르지 않은 email, password에서 AdminInvalidPasswordException를 발생', async () => { await transactionTest(dataSource, async () => { // given const user = await usersFixture.getUser('ABC'); - const admin = new Admin(user, 'abc123@abc.com', '1234'); + const admin = new Admin(user, 'abc124@abc.com', '1234'); admin.status = AdminStatus.ACTIVE; await adminRepository.saveAdmin(admin); - const adminLogin = new AdminLogin('abc123@abc.com', '12345'); + const adminLogin = new AdminLogin('abc124@abc.com', '12345'); // when // then @@ -120,4 +142,38 @@ describe('AdminService Test', () => { ); }); }); + + it('acceptAdminRegister는 PENDING 상태의 어드민을 ACTIVE 상태로 변경한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const masterAdmin = await adminFixture.getAdmin('ABC'); + + const userRequester = await usersFixture.getUser('ABC'); + const adminRequester = new Admin(userRequester, 'abc123@abc.com', '1234'); + const savedAdmin = await adminRepository.saveAdmin(adminRequester); + + const newAcceptedAdmin = await adminService.acceptAdminRegister( + masterAdmin.user, + savedAdmin.email, + ); + + expect(newAcceptedAdmin.status).toBe(AdminStatus.ACTIVE); + expect(newAcceptedAdmin.user.roles).toContain(UserRole.ADMIN); + expect(newAcceptedAdmin.user.roles).toContain(UserRole.MEMBER); + }); + }); + + it('acceptAdminRegister는 ACTIVE 상태의 어드민에 대한 전환요청에 대해 UserNotAdminPendingStatusException를 발생시킨다.', async () => { + await transactionTest(dataSource, async () => { + // given + const masterAdmin = await adminFixture.getAdmin('ABC1'); + const alreadyAdmin = await adminFixture.getAdmin('ABC2'); + + // when + // then + await expect( + adminService.acceptAdminRegister(masterAdmin.user, alreadyAdmin.email), + ).rejects.toThrow(UserNotAdminPendingStatusException); + }); + }); }); diff --git a/BE/src/admin/application/admin.service.ts b/BE/src/admin/application/admin.service.ts index 4d3d4911..7c1f3e8c 100644 --- a/BE/src/admin/application/admin.service.ts +++ b/BE/src/admin/application/admin.service.ts @@ -50,4 +50,14 @@ export class AdminService { await registerAdmin.register(this.passwordEncoder); return this.adminRepository.saveAdmin(registerAdmin); } + + @Transactional() + async acceptAdminRegister(accepter: User, email: string): Promise { + const admin = await this.adminRepository.findPendingAdminByEmail(email); + if (!admin) throw new UserNotAdminPendingStatusException(); + + admin.accepted(); + await this.userRoleRepository.saveUserRole(admin.user, UserRole.ADMIN); + return this.adminRepository.saveAdmin(admin); + } } diff --git a/BE/src/admin/exception/admin-invalid-password.ts b/BE/src/admin/exception/admin-invalid-password.exception.ts similarity index 100% rename from BE/src/admin/exception/admin-invalid-password.ts rename to BE/src/admin/exception/admin-invalid-password.exception.ts From 396b7370943a12e57bb38f42658724ecc640fa64 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:28:36 +0900 Subject: [PATCH 036/188] =?UTF-8?q?[BE]=20feat:=20AccessTokenGuard=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저 권한 정보를 포함해서 유저를 조회하는 함수로 교체 --- BE/src/auth/guard/access-token.guard.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/BE/src/auth/guard/access-token.guard.ts b/BE/src/auth/guard/access-token.guard.ts index 22103370..db482766 100644 --- a/BE/src/auth/guard/access-token.guard.ts +++ b/BE/src/auth/guard/access-token.guard.ts @@ -1,8 +1,6 @@ import { CanActivate, ExecutionContext, - forwardRef, - Inject, Injectable, UnauthorizedException, } from '@nestjs/common'; @@ -29,7 +27,7 @@ export class AccessTokenGuard implements CanActivate { const payloads = this.jwtUtils.parsePayloads(splitToken[1]); const userCode = payloads.userCode; - req.user = await this.usersService.findOneByUserCode(userCode); + req.user = await this.usersService.getUserByUserCodeWithRoles(userCode); return true; } From bd6ec89cb9cebaedb107487400ff4e99c5978ea2 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:29:03 +0900 Subject: [PATCH 037/188] =?UTF-8?q?[BE]=20feat:=20AdminTokenGuard=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어드민 권한을 위한 Guard 생성 --- BE/src/auth/guard/admin-token-guard.spec.ts | 65 +++++++++++++++++++++ BE/src/auth/guard/admin-token.guard.ts | 17 ++++++ 2 files changed, 82 insertions(+) create mode 100644 BE/src/auth/guard/admin-token-guard.spec.ts create mode 100644 BE/src/auth/guard/admin-token.guard.ts diff --git a/BE/src/auth/guard/admin-token-guard.spec.ts b/BE/src/auth/guard/admin-token-guard.spec.ts new file mode 100644 index 00000000..8761d8a0 --- /dev/null +++ b/BE/src/auth/guard/admin-token-guard.spec.ts @@ -0,0 +1,65 @@ +import { AdminTokenGuard } from './admin-token.guard'; +import { User } from '../../users/domain/user.domain'; +import { UserRole } from '../../users/domain/user-role'; + +describe('AdminTokenGuard Test', () => { + const tokenGuard = new AdminTokenGuard(undefined, undefined); + + it('tokenGuard이 정의되어있다.', () => { + expect(tokenGuard).toBeDefined(); + }); + + describe('validateAdminUser는 유저에 ADMIN 권한이 있는지 확인한다.', () => { + it('유저가 빈 값일 때 false를 반환한다.', () => { + expect(tokenGuard.validateAdminUser(undefined)).toBeFalsy(); + expect(tokenGuard.validateAdminUser(null)).toBeFalsy(); + }); + + it('유저에 ADMIN 권한이 없을 때 false를 반환한다.', () => { + // given + const user = new User(); + user.roles = [UserRole.MEMBER]; + + // when + const result = tokenGuard.validateAdminUser(user); + + // then + expect(result).toBeFalsy(); + }); + + it('유저에 권한이 비어있을 때 false를 반환한다.', () => { + // given + const user = new User(); + + // when + const result = tokenGuard.validateAdminUser(user); + + // then + expect(result).toBeFalsy(); + }); + + it('유저에 MEMBER, ADMIN 권한이 있을 때 true를 반환한다.', () => { + // given + const user = new User(); + user.roles = [UserRole.MEMBER, UserRole.ADMIN]; + + // when + const result = tokenGuard.validateAdminUser(user); + + // then + expect(result).toBeTruthy(); + }); + + it('유저에 ADMIN 권한이 있을 때 true를 반환한다.', () => { + // given + const user = new User(); + user.roles = [UserRole.ADMIN]; + + // when + const result = tokenGuard.validateAdminUser(user); + + // then + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/BE/src/auth/guard/admin-token.guard.ts b/BE/src/auth/guard/admin-token.guard.ts new file mode 100644 index 00000000..6ac5aa65 --- /dev/null +++ b/BE/src/auth/guard/admin-token.guard.ts @@ -0,0 +1,17 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AccessTokenGuard } from './access-token.guard'; +import { UserRole } from '../../users/domain/user-role'; +import { User } from '../../users/domain/user.domain'; + +@Injectable() +export class AdminTokenGuard extends AccessTokenGuard { + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + if (!(await super.canActivate(context))) return false; + return this.validateAdminUser(req.user); + } + + validateAdminUser(user: User): boolean { + return user?.roles?.filter((r) => r === UserRole.ADMIN).length > 0; + } +} From 57f6fb53085d8c1954abb61f3135b23401629616 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 22:29:37 +0900 Subject: [PATCH 038/188] =?UTF-8?q?[BE]=20feat:=20AdminRestController?= =?UTF-8?q?=EC=97=90=20acceptAdminRegister=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어드민 권한 수락 메서드 --- BE/src/admin/admin.module.ts | 8 ++++++- .../admin/controller/admin-rest.controller.ts | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/BE/src/admin/admin.module.ts b/BE/src/admin/admin.module.ts index d6427857..6d6e93f4 100644 --- a/BE/src/admin/admin.module.ts +++ b/BE/src/admin/admin.module.ts @@ -6,10 +6,16 @@ import { CustomTypeOrmModule } from '../config/typeorm/custom-typeorm.module'; import { AdminRepository } from './entities/admin.repository'; import { passwordEncoderProviderOptions } from './application/password-encoder'; import { UsersModule } from '../users/users.module'; +import { UserRepository } from '../users/entities/user.repository'; +import { UsersRoleRepository } from '../users/entities/users-role.repository'; @Module({ imports: [ - CustomTypeOrmModule.forCustomRepository([AdminRepository]), + CustomTypeOrmModule.forCustomRepository([ + AdminRepository, + UserRepository, + UsersRoleRepository, + ]), AuthModule, UsersModule, ], diff --git a/BE/src/admin/controller/admin-rest.controller.ts b/BE/src/admin/controller/admin-rest.controller.ts index 59770655..cd7094c1 100644 --- a/BE/src/admin/controller/admin-rest.controller.ts +++ b/BE/src/admin/controller/admin-rest.controller.ts @@ -1,4 +1,11 @@ -import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + HttpCode, + Post, + Query, + UseGuards, +} from '@nestjs/common'; import { AdminService } from '../application/admin.service'; import { AccessTokenGuard } from '../../auth/guard/access-token.guard'; import { AdminRegister } from '../dto/admin-register'; @@ -8,6 +15,7 @@ import { AdminLogin } from '../dto/admin-login'; import { ApiData } from '../../common/api/api-data'; import { AdminToken } from '../dto/admin-token'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AdminTokenGuard } from '../../auth/guard/admin-token.guard'; @Controller('/api/v1/admin') @ApiTags('어드민 API') @@ -42,4 +50,18 @@ export class AdminRestController { const adminToken = await this.adminService.loginAdmin(loginRequest); return ApiData.success(AdminToken.from(adminToken)); } + + @UseGuards(AdminTokenGuard) + @Post('/register/accept') + @ApiOperation({ + summary: '어드민 요청 수락 API', + description: '어드민 계정만 요청을 수락 가능', + }) + async acceptAdminRegister( + @AuthenticatedUser() accepter: User, + @Query() email: string, + ) { + await this.adminService.acceptAdminRegister(accepter, email); + return ApiData.success('어드민 등록 완료'); + } } From 51da4d8d73938526fc72d3337e7669f71271ffa8 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Sun, 19 Nov 2023 23:51:06 +0900 Subject: [PATCH 039/188] =?UTF-8?q?[BE]=20fix:=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=EC=9D=B8=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "loginAdmin은 존재 하지 않는 email의 Admin에 대해 UserNotAdminPendingStatusException를 발생시킨다." "loginAdmin은 잘못된 password에 대해 AdminInvalidPasswordException를 발생시킨다" 로 수정 --- BE/src/admin/application/admin.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BE/src/admin/application/admin.service.spec.ts b/BE/src/admin/application/admin.service.spec.ts index c3206f85..6cbcb368 100644 --- a/BE/src/admin/application/admin.service.spec.ts +++ b/BE/src/admin/application/admin.service.spec.ts @@ -109,7 +109,7 @@ describe('AdminService Test', () => { }); }); - it('loginAdmin은 존재 하지 않는 email의 Admin에 대해 UserNotAdminPendingStatusException를 발생시킨다.', async () => { + it('loginAdmin은 잘못된 password에 대해 AdminInvalidPasswordException를 발생시킨다.', async () => { await transactionTest(dataSource, async () => { // given const user = await usersFixture.getUser('ABC'); From ad43d80af1a1c7175e0ff855397ca8de58584319 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 13:11:43 +0900 Subject: [PATCH 040/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cursor를 제거하고, nextUrl을 path 형태의 스트링이 아닌, json 형태로 수정한다. --- .../dto/paginate-achievement-response.spec.ts | 12 ++++----- .../dto/paginate-achievement-response.ts | 25 +++++++------------ BE/src/achievement/index.ts | 5 ++++ 3 files changed, 20 insertions(+), 22 deletions(-) create mode 100644 BE/src/achievement/index.ts diff --git a/BE/src/achievement/dto/paginate-achievement-response.spec.ts b/BE/src/achievement/dto/paginate-achievement-response.spec.ts index 46d777a4..75a752bf 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.spec.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.spec.ts @@ -18,9 +18,7 @@ describe('PaginateAchievementResponse test', () => { paginateAchievementRequest, achievementResponses.slice(0, paginateAchievementRequest.take), ); - expect(response.next).toEqual( - '/api/v1/achievements?&take=12&where__id__less_than=88', - ); + expect(response.next).toEqual({ take: 12, where__id__less_than: 88 }); }); test('next url을 생성한다. - categoryId 필터링 추가', () => { const paginateAchievementRequest = new PaginateAchievementRequest(); @@ -30,8 +28,10 @@ describe('PaginateAchievementResponse test', () => { paginateAchievementRequest, achievementResponses.slice(0, paginateAchievementRequest.take), ); - expect(response.next).toEqual( - '/api/v1/achievements?&take=12&categoryId=1&where__id__less_than=88', - ); + expect(response.next).toEqual({ + categoryId: 1, + take: 12, + where__id__less_than: 88, + }); }); }); diff --git a/BE/src/achievement/dto/paginate-achievement-response.ts b/BE/src/achievement/dto/paginate-achievement-response.ts index ff6b0d02..32f8158b 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.ts @@ -1,14 +1,11 @@ import { AchievementResponse } from './achievement-response'; import { PaginateAchievementRequest } from './paginate-achievement-request'; +import { Next } from '../index'; export class PaginateAchievementResponse { - private basePath = '/api/v1/achievements?'; data: AchievementResponse[]; - cursor: { - after: number; - }; count: number; - next: string; + next: Next | null; constructor( paginateAchievementRequest: PaginateAchievementRequest, @@ -22,32 +19,28 @@ export class PaginateAchievementResponse { ? achievements[achievements.length - 1] : null; - this.cursor = { - after: last?.id ?? null, - }; - this.count = achievements.length; - this.next = this.makeNextUrl(paginateAchievementRequest, last); + this.next = this.makeNext(paginateAchievementRequest, last); } - private makeNextUrl( + private makeNext( paginateAchievementRequest: PaginateAchievementRequest, last: AchievementResponse, ) { - const nextUrl = last && [this.basePath]; + const next: Next | null = last && {}; - if (nextUrl) { + if (next) { for (const key of Object.keys(paginateAchievementRequest)) { if (!paginateAchievementRequest[key]) { continue; } if (key !== 'where__id__less_than') { - nextUrl.push(`${key}=${paginateAchievementRequest[key]}`); + next[key] = paginateAchievementRequest[key]; } } - nextUrl.push(`where__id__less_than=${last.id.toString()}`); + next.where__id__less_than = parseInt(last.id.toString()); } - return nextUrl?.join('&').toString() ?? null; + return next ?? null; } } diff --git a/BE/src/achievement/index.ts b/BE/src/achievement/index.ts new file mode 100644 index 00000000..c7776d2b --- /dev/null +++ b/BE/src/achievement/index.ts @@ -0,0 +1,5 @@ +export interface Next { + where__id__less_than?: number; + take?: number; + categoryId?: number; +} From f01995ca6e180d10bdfbd4bb2c619f2583af35aa Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 13:16:01 +0900 Subject: [PATCH 041/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커서 기반의 페이지네이션 적용 --- .../application/achievement.service.spec.ts | 92 +++++++++++++++++++ .../application/achievement.service.ts | 23 +++++ .../controller/achievement.controller.ts | 25 +++++ 3 files changed, 140 insertions(+) create mode 100644 BE/src/achievement/application/achievement.service.spec.ts create mode 100644 BE/src/achievement/application/achievement.service.ts create mode 100644 BE/src/achievement/controller/achievement.controller.ts diff --git a/BE/src/achievement/application/achievement.service.spec.ts b/BE/src/achievement/application/achievement.service.spec.ts new file mode 100644 index 00000000..c7973853 --- /dev/null +++ b/BE/src/achievement/application/achievement.service.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AchievementService } from './achievement.service'; +import { CustomTypeOrmModule } from '../../config/typeorm/custom-typeorm.module'; +import { AchievementRepository } from '../entities/achievement.repository'; +import { ConfigModule } from '@nestjs/config'; +import { configServiceModuleOptions } from '../../config/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { typeOrmModuleOptions } from '../../config/typeorm'; +import { UsersTestModule } from '../../../test/user/users-test.module'; +import { UsersFixture } from '../../../test/user/users-fixture'; +import { CategoryFixture } from '../../../test/category/category-fixture'; +import { AchievementFixture } from '../../../test/achievement/achievement-fixture'; +import { CategoryTestModule } from '../../../test/category/category-test.module'; +import { AchievementTestModule } from '../../../test/achievement/achievement-test.module'; +import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; + +describe('AchievementService Test', () => { + let achievementService: AchievementService; + let usersFixture: UsersFixture; + let categoryFixture: CategoryFixture; + let achievementFixture: AchievementFixture; + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(configServiceModuleOptions), + TypeOrmModule.forRootAsync(typeOrmModuleOptions), + CustomTypeOrmModule.forCustomRepository([AchievementRepository]), + UsersTestModule, + CategoryTestModule, + AchievementTestModule, + ], + providers: [AchievementService], + }).compile(); + + achievementService = module.get(AchievementService); + usersFixture = module.get(UsersFixture); + categoryFixture = module.get(CategoryFixture); + achievementFixture = module.get(AchievementFixture); + }); + + test('개인 달성 기록 리스트에 대한 페이지네이션 조회를 할 수 있다.', async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + const achievements = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + + // when + const firstRequest = new PaginateAchievementRequest(category.id, 4); + const firstResponse = await achievementService.getAchievements( + user.id, + firstRequest, + ); + + const nextRequest = new PaginateAchievementRequest( + category.id, + 4, + firstResponse.next.where__id__less_than, + ); + const nextResponse = await achievementService.getAchievements( + user.id, + nextRequest, + ); + + const lastRequest = new PaginateAchievementRequest( + category.id, + 4, + nextResponse.next.where__id__less_than, + ); + const lastResponse = await achievementService.getAchievements( + user.id, + lastRequest, + ); + + expect(firstResponse.count).toEqual(4); + expect(firstResponse.data.length).toEqual(4); + expect(firstResponse.next.where__id__less_than).toEqual(7); + + expect(nextResponse.count).toEqual(4); + expect(nextResponse.data.length).toEqual(4); + expect(nextResponse.next.where__id__less_than).toEqual(3); + + expect(lastResponse.count).toEqual(2); + expect(lastResponse.data.length).toEqual(2); + expect(lastResponse.next).toEqual(null); + console.log(lastResponse); + }); +}); diff --git a/BE/src/achievement/application/achievement.service.ts b/BE/src/achievement/application/achievement.service.ts new file mode 100644 index 00000000..0214caeb --- /dev/null +++ b/BE/src/achievement/application/achievement.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { AchievementRepository } from '../entities/achievement.repository'; +import { AchievementResponse } from '../dto/achievement-response'; +import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; +import { PaginateAchievementResponse } from '../dto/paginate-achievement-response'; + +@Injectable() +export class AchievementService { + constructor(private readonly achievementRepository: AchievementRepository) {} + async getAchievements( + userId: number, + paginateAchievementRequest: PaginateAchievementRequest, + ) { + const achievements = await this.achievementRepository.findAll( + userId, + paginateAchievementRequest, + ); + return new PaginateAchievementResponse( + paginateAchievementRequest, + achievements.map((achievement) => AchievementResponse.from(achievement)), + ); + } +} diff --git a/BE/src/achievement/controller/achievement.controller.ts b/BE/src/achievement/controller/achievement.controller.ts new file mode 100644 index 00000000..f6321246 --- /dev/null +++ b/BE/src/achievement/controller/achievement.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AchievementService } from '../application/achievement.service'; +import { AccessTokenGuard } from '../../auth/guard/access-token.guard'; +import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decorator'; +import { User } from '../../users/domain/user.domain'; +import { ApiData } from '../../common/api/api-data'; +import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; + +@Controller('/api/v1/achievements') +export class AchievementController { + constructor(private readonly achievementService: AchievementService) {} + + @Get() + @UseGuards(AccessTokenGuard) + async getAchievements( + @AuthenticatedUser() user: User, + @Query() paginateAchievementRequest: PaginateAchievementRequest, + ) { + const response = await this.achievementService.getAchievements( + user.id, + paginateAchievementRequest, + ); + return ApiData.success(response); + } +} From 58b5a37c7828e8eff17130cc92e6812b8e764cba Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 13:26:50 +0900 Subject: [PATCH 042/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20swagger=20=EC=86=8D=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../achievement/controller/achievement.controller.ts | 11 +++++++++++ BE/src/achievement/dto/achievement-response.ts | 4 ++++ .../achievement/dto/paginate-achievement-request.ts | 4 ++++ .../achievement/dto/paginate-achievement-response.ts | 4 ++++ 4 files changed, 23 insertions(+) diff --git a/BE/src/achievement/controller/achievement.controller.ts b/BE/src/achievement/controller/achievement.controller.ts index f6321246..20ca766d 100644 --- a/BE/src/achievement/controller/achievement.controller.ts +++ b/BE/src/achievement/controller/achievement.controller.ts @@ -5,13 +5,24 @@ import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decora import { User } from '../../users/domain/user.domain'; import { ApiData } from '../../common/api/api-data'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; +import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PaginateAchievementResponse } from '../dto/paginate-achievement-response'; @Controller('/api/v1/achievements') +@ApiTags('achievement API') export class AchievementController { constructor(private readonly achievementService: AchievementService) {} @Get() @UseGuards(AccessTokenGuard) + @ApiOperation({ + summary: '달성기록 리스트 API', + description: '달성기록 리스트를 커서 페이지네이션 기반으로 조회한다.', + }) + @ApiCreatedResponse({ + description: '달성기록 리스트', + type: PaginateAchievementResponse, + }) async getAchievements( @AuthenticatedUser() user: User, @Query() paginateAchievementRequest: PaginateAchievementRequest, diff --git a/BE/src/achievement/dto/achievement-response.ts b/BE/src/achievement/dto/achievement-response.ts index 0591b177..f1fcfa88 100644 --- a/BE/src/achievement/dto/achievement-response.ts +++ b/BE/src/achievement/dto/achievement-response.ts @@ -1,8 +1,12 @@ import { Achievement } from '../domain/achievement.domain'; +import { ApiProperty } from '@nestjs/swagger'; export class AchievementResponse { + @ApiProperty({ description: 'id' }) id: number; + @ApiProperty({ description: 'string' }) thumbnailUrl: string; + @ApiProperty({ description: 'string' }) title: string; constructor(id: number, thumbnailUrl: string, title: string) { diff --git a/BE/src/achievement/dto/paginate-achievement-request.ts b/BE/src/achievement/dto/paginate-achievement-request.ts index 31331b48..37d286ee 100644 --- a/BE/src/achievement/dto/paginate-achievement-request.ts +++ b/BE/src/achievement/dto/paginate-achievement-request.ts @@ -1,16 +1,20 @@ import { IsNumber, IsOptional } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class PaginateAchievementRequest { @IsNumber() @IsOptional() + @ApiProperty({ description: 'next cursor id' }) where__id__less_than?: number; @IsNumber() @IsOptional() + @ApiProperty({ description: 'take' }) take: number = 12; @IsNumber() @IsOptional() + @ApiProperty({ description: 'categoryId' }) categoryId: number; constructor( diff --git a/BE/src/achievement/dto/paginate-achievement-response.ts b/BE/src/achievement/dto/paginate-achievement-response.ts index 32f8158b..c550957d 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.ts @@ -1,10 +1,14 @@ import { AchievementResponse } from './achievement-response'; import { PaginateAchievementRequest } from './paginate-achievement-request'; import { Next } from '../index'; +import { ApiProperty } from '@nestjs/swagger'; export class PaginateAchievementResponse { + @ApiProperty({ type: [AchievementResponse], description: 'data' }) data: AchievementResponse[]; + @ApiProperty({ description: 'count' }) count: number; + @ApiProperty({ description: 'next' }) next: Next | null; constructor( From 06dd9ee74a52ce94b5a5c2545a0b75dc464d75c3 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 13:27:30 +0900 Subject: [PATCH 043/188] =?UTF-8?q?[BE]=20feat:=20achievement=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/achievement.module.ts | 12 ++++++++++++ BE/src/app.module.ts | 2 ++ 2 files changed, 14 insertions(+) create mode 100644 BE/src/achievement/achievement.module.ts diff --git a/BE/src/achievement/achievement.module.ts b/BE/src/achievement/achievement.module.ts new file mode 100644 index 00000000..23e57afc --- /dev/null +++ b/BE/src/achievement/achievement.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AchievementController } from './controller/achievement.controller'; +import { AchievementService } from './application/achievement.service'; +import { CustomTypeOrmModule } from '../config/typeorm/custom-typeorm.module'; +import { AchievementRepository } from './entities/achievement.repository'; + +@Module({ + imports: [CustomTypeOrmModule.forCustomRepository([AchievementRepository])], + controllers: [AchievementController], + providers: [AchievementService], +}) +export class AchievementModule {} diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index 4101ebfc..56b859ed 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -10,6 +10,7 @@ import { TransactionModule } from './config/transaction-manager/transaction.modu import { OperateModule } from './operate/operate.module'; import { UsersModule } from './users/users.module'; import { CategoryModule } from './category/category.module'; +import { AchievementModule } from './achievement/achievement.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { CategoryModule } from './category/category.module'; TransactionModule, OperateModule, CategoryModule, + AchievementModule, ], controllers: [AppController], providers: [AppService], From 3d96b315b744e8d2921863247f36e0bf0cc5c880 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 14:02:27 +0900 Subject: [PATCH 044/188] =?UTF-8?q?[BE]=20fix:=20swagger=20tag=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=EC=9D=84=20=ED=95=9C=EA=B8=80=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/controller/achievement.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BE/src/achievement/controller/achievement.controller.ts b/BE/src/achievement/controller/achievement.controller.ts index 20ca766d..63f925fe 100644 --- a/BE/src/achievement/controller/achievement.controller.ts +++ b/BE/src/achievement/controller/achievement.controller.ts @@ -9,7 +9,7 @@ import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PaginateAchievementResponse } from '../dto/paginate-achievement-response'; @Controller('/api/v1/achievements') -@ApiTags('achievement API') +@ApiTags('달성기록 API') export class AchievementController { constructor(private readonly achievementService: AchievementService) {} From 57ecb8659622dbb30a697630a0b49727c881a23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Mon, 20 Nov 2023 16:35:58 +0900 Subject: [PATCH 045/188] =?UTF-8?q?[iOS]=20feat:=20LaunchVM=EC=97=90=20Act?= =?UTF-8?q?ion,=20State=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moti/Application/AppCoordinator.swift | 2 +- .../Launch/LaunchViewController.swift | 39 ++++++---- .../Presentation/Launch/LaunchViewModel.swift | 71 ++++++++++++++----- 3 files changed, 78 insertions(+), 34 deletions(-) diff --git a/iOS/moti/moti/Application/AppCoordinator.swift b/iOS/moti/moti/Application/AppCoordinator.swift index 31e25329..7880568a 100644 --- a/iOS/moti/moti/Application/AppCoordinator.swift +++ b/iOS/moti/moti/Application/AppCoordinator.swift @@ -24,7 +24,7 @@ final class AppCoordinator: Coordinator { } func start() { - moveHomeViewController() + moveLaunchViewController() } private func moveLaunchViewController() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewController.swift index bfda9df1..89c4226c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewController.swift @@ -36,31 +36,42 @@ final class LaunchViewController: BaseViewController { super.viewDidLoad() bind() - viewModel.fetchVersion() + viewModel.action(.launch) } private func bind() { - viewModel.$version - .dropFirst() + viewModel.$versionState .receive(on: RunLoop.main) - .sink { [weak self] version in + .sink { [weak self] state in guard let self else { return } - - sleep(1) - viewModel.fetchToken() + switch state { + case .none, .loading: break + case .finish: + viewModel.action(.autoLogin) + case .error(let message): + Logger.error("Launch Version Error: \(message)") + } } .store(in: &cancellables) - viewModel.$isSuccessLogin - .dropFirst() + viewModel.$autoLoginState .receive(on: RunLoop.main) - .sink { [weak self] isSuccessLogin in + .sink { [weak self] state in guard let self else { return } - - delegate?.viewControllerDidLogin(isSuccess: isSuccessLogin) - coordinator?.finish(animated: false) + switch state { + case .none: break + case .loading: + Logger.debug("자동 로그인 진행 중") + case .success: + Logger.debug("자동 로그인 성공") + delegate?.viewControllerDidLogin(isSuccess: true) + coordinator?.finish(animated: false) + case .failed(let message): + Logger.debug("자동 로그인 실패: \(message)") + delegate?.viewControllerDidLogin(isSuccess: false) + coordinator?.finish(animated: false) + } } .store(in: &cancellables) - } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift index 0a985e38..ff3fa7d4 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift @@ -10,13 +10,30 @@ import Domain import Core final class LaunchViewModel { + enum LaunchViewModelAction { + case launch + case autoLogin + } + + enum AutoLoginState { + case none + case loading + case success + case failed(message: String) + } + + enum VersionState { + case none + case loading + case finish(version: Version) + case error(message: String) + } private let fetchVersionUseCase: FetchVersionUseCase private let autoLoginUseCase: AutoLoginUseCase - @Published private(set) var version: Version? - @Published private(set) var isSuccessLogin = false - private var token: UserToken? + @Published private(set) var versionState: VersionState = .none + @Published private(set) var autoLoginState: AutoLoginState = .none init( fetchVersionUseCase: FetchVersionUseCase, @@ -26,49 +43,65 @@ final class LaunchViewModel { self.autoLoginUseCase = autoLoginUseCase } - func fetchVersion() { + func action(_ action: LaunchViewModelAction) { + switch action { + case .launch: + fetchVersion() + case .autoLogin: + if let refreshToken = fetchRefreshToken() { + Logger.debug("refreshToken으로 자동 로그인 시도") + requestAutoLogin(using: refreshToken) + } else { + Logger.debug("자동 로그인 실패") + resetToken() + } + } + } + + private func fetchVersion() { Task { do { - version = try await fetchVersionUseCase.execute() + versionState = .loading + + let version = try await fetchVersionUseCase.execute() Logger.debug("version: \(String(describing: version))") + + versionState = .finish(version: version) } catch { Logger.debug("version error: \(error)") + versionState = .error(message: error.localizedDescription) } } } - func fetchToken() { + private func fetchRefreshToken() -> String? { // Keychain 저장소로 변경 - if let refreshToken = UserDefaults.standard.string(forKey: "refreshToken") { - Logger.debug("refreshToken으로 자동 로그인 시도") - requestAutoLogin(using: refreshToken) - } else { - Logger.debug("자동 로그인 실패") - resetToken() - isSuccessLogin = false - } + return UserDefaults.standard.string(forKey: "refreshToken") } - func requestAutoLogin(using refreshToken: String) { + private func requestAutoLogin(using refreshToken: String) { Task { do { + autoLoginState = .loading + let requestValue = AutoLoginRequestValue(refreshToken: refreshToken) let token = try await autoLoginUseCase.excute(requestValue: requestValue) saveAccessToken(token.accessToken) - isSuccessLogin = true + + autoLoginState = .success } catch { - isSuccessLogin = false Logger.error(error) + autoLoginState = .failed(message: error.localizedDescription) } } } // TODO: UserDefaultsStorage로 변경해서 UseCase로 사용하기 - func saveAccessToken(_ accessToken: String) { + private func saveAccessToken(_ accessToken: String) { UserDefaults.standard.setValue(accessToken, forKey: "accessToken") } - func resetToken() { + private func resetToken() { UserDefaults.standard.removeObject(forKey: "refreshToken") UserDefaults.standard.removeObject(forKey: "accessToken") } From 6de3ef78d9c5e3c0472cf4564be542f597c7e119 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 18:53:40 +0900 Subject: [PATCH 046/188] =?UTF-8?q?[iOS]=20feat:=20AlertFactory=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 텍스트 필드가 없는 normalAlert - 텍스트 필드가 있는 alert --- .../Design/Sources/Design/AlertFactory.swift | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 iOS/moti/moti/Design/Sources/Design/AlertFactory.swift diff --git a/iOS/moti/moti/Design/Sources/Design/AlertFactory.swift b/iOS/moti/moti/Design/Sources/Design/AlertFactory.swift new file mode 100644 index 00000000..a7ca4871 --- /dev/null +++ b/iOS/moti/moti/Design/Sources/Design/AlertFactory.swift @@ -0,0 +1,46 @@ +// +// AlertFactory.swift +// +// +// Created by Kihyun Lee on 11/20/23. +// + +import UIKit + +public enum AlertFactory { + public static func makeNormalAlert( + title: String? = nil, + message: String? = nil, + okAction: @escaping () -> Void + ) -> UIAlertController { + let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert) + let okAlert = UIAlertAction(title: "OK", style: .default) { _ in + okAction() + } + let cancelAlert = UIAlertAction(title: "cancel", style: .cancel) + + alertVC.addAction(cancelAlert) + alertVC.addAction(okAlert) + return alertVC + } + + public static func makeTextFieldAlert( + title: String? = nil, + message: String? = nil, + placeholder: String? = nil, + okAction: @escaping (String?) -> Void + ) -> UIAlertController { + let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert) + let okAlert = UIAlertAction(title: "OK", style: .default) { _ in + okAction(alertVC.textFields?[0].text) + } + let cancelAlert = UIAlertAction(title: "cancel", style: .cancel) + + alertVC.addAction(cancelAlert) + alertVC.addAction(okAlert) + alertVC.addTextField { myTextField in + myTextField.placeholder = placeholder + } + return alertVC + } +} From a2949eaaf8121f7dbaa8ce4e8e858c9653078db5 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 18:54:13 +0900 Subject: [PATCH 047/188] =?UTF-8?q?[iOS]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80(+)=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20alert=20=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Home/HomeView.swift | 2 +- .../Presentation/Home/HomeViewController.swift | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift index 6de2a674..1d977f51 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift @@ -12,7 +12,7 @@ final class HomeView: UIView { // MARK: - Views // 카테고리 추가 버튼 - private let catergoryAddButton: BounceButton = { + let catergoryAddButton: BounceButton = { let button = BounceButton() button.setTitle("+", for: .normal) return button diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 631f0866..786a325d 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -28,6 +28,7 @@ final class HomeViewController: BaseViewController { // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() + addTargets() setupAchievementDataSource() setupCategoryDataSource() @@ -42,6 +43,23 @@ final class HomeViewController: BaseViewController { }) } + // MARK: - Methods + private func addTargets() { + layoutView.catergoryAddButton.addTarget(self, action: #selector(showAlert), for: .touchUpInside) + } + + @objc private func showAlert() { + let alertVC = AlertFactory.makeTextFieldAlert( + title: "추가할 카테고리 이름을 정해주세요.", + message: "ex) 다이어트", + placeholder: "다이어트" + ) { (text) in + Logger.debug(text) + } + + present(alertVC, animated: true) + } + // MARK: - Setup private func setupAchievementDataSource() { layoutView.achievementCollectionView.delegate = self From 9e8ece3aee27abd847cff8559eb3a5267a77efea Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 19:05:43 +0900 Subject: [PATCH 048/188] =?UTF-8?q?[BE]=20fix:=20test=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/dto/paginate-achievement-response.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BE/src/achievement/dto/paginate-achievement-response.spec.ts b/BE/src/achievement/dto/paginate-achievement-response.spec.ts index 75a752bf..bc1d5763 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.spec.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.spec.ts @@ -11,7 +11,7 @@ describe('PaginateAchievementResponse test', () => { ); } - test('next url을 생성한다.', () => { + test('다음 페이지 네이션 요청을 위한 정보를 가지고 있는 next을 생성한다.', () => { const paginateAchievementRequest = new PaginateAchievementRequest(); paginateAchievementRequest.take = 12; const response = new PaginateAchievementResponse( @@ -20,7 +20,7 @@ describe('PaginateAchievementResponse test', () => { ); expect(response.next).toEqual({ take: 12, where__id__less_than: 88 }); }); - test('next url을 생성한다. - categoryId 필터링 추가', () => { + test('다음 페이지 네이션 요청을 위한 정보를 가지고 있는 next을 생성한다. - categoryId 필터링 추가', () => { const paginateAchievementRequest = new PaginateAchievementRequest(); paginateAchievementRequest.take = 12; paginateAchievementRequest.categoryId = 1; From a5240fa2dddf5116f1dec0d1a4937a5911644415 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 19:30:35 +0900 Subject: [PATCH 049/188] =?UTF-8?q?[BE]=20refactor:=20where=5F=5Fid=5F=5Fl?= =?UTF-8?q?ess=5Fthan=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9D=84=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=B9=B4?= =?UTF-8?q?=EB=A9=9C=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/achievement.service.spec.ts | 9 ++++----- .../achievement/dto/paginate-achievement-request.ts | 10 +++------- .../dto/paginate-achievement-response.spec.ts | 4 ++-- .../achievement/dto/paginate-achievement-response.ts | 4 ++-- .../achievement/entities/achievement.repository.ts | 12 ++++-------- BE/src/achievement/index.ts | 2 +- 6 files changed, 16 insertions(+), 25 deletions(-) diff --git a/BE/src/achievement/application/achievement.service.spec.ts b/BE/src/achievement/application/achievement.service.spec.ts index c7973853..bfaad970 100644 --- a/BE/src/achievement/application/achievement.service.spec.ts +++ b/BE/src/achievement/application/achievement.service.spec.ts @@ -59,7 +59,7 @@ describe('AchievementService Test', () => { const nextRequest = new PaginateAchievementRequest( category.id, 4, - firstResponse.next.where__id__less_than, + firstResponse.next.whereIdLessThan, ); const nextResponse = await achievementService.getAchievements( user.id, @@ -69,7 +69,7 @@ describe('AchievementService Test', () => { const lastRequest = new PaginateAchievementRequest( category.id, 4, - nextResponse.next.where__id__less_than, + nextResponse.next.whereIdLessThan, ); const lastResponse = await achievementService.getAchievements( user.id, @@ -78,15 +78,14 @@ describe('AchievementService Test', () => { expect(firstResponse.count).toEqual(4); expect(firstResponse.data.length).toEqual(4); - expect(firstResponse.next.where__id__less_than).toEqual(7); + expect(firstResponse.next.whereIdLessThan).toEqual(7); expect(nextResponse.count).toEqual(4); expect(nextResponse.data.length).toEqual(4); - expect(nextResponse.next.where__id__less_than).toEqual(3); + expect(nextResponse.next.whereIdLessThan).toEqual(3); expect(lastResponse.count).toEqual(2); expect(lastResponse.data.length).toEqual(2); expect(lastResponse.next).toEqual(null); - console.log(lastResponse); }); }); diff --git a/BE/src/achievement/dto/paginate-achievement-request.ts b/BE/src/achievement/dto/paginate-achievement-request.ts index 37d286ee..4f1f1c7d 100644 --- a/BE/src/achievement/dto/paginate-achievement-request.ts +++ b/BE/src/achievement/dto/paginate-achievement-request.ts @@ -5,7 +5,7 @@ export class PaginateAchievementRequest { @IsNumber() @IsOptional() @ApiProperty({ description: 'next cursor id' }) - where__id__less_than?: number; + whereIdLessThan?: number; @IsNumber() @IsOptional() @@ -17,13 +17,9 @@ export class PaginateAchievementRequest { @ApiProperty({ description: 'categoryId' }) categoryId: number; - constructor( - categoryId?: number, - take?: number, - where__id__less_than?: number, - ) { + constructor(categoryId?: number, take?: number, whereIdLessThan?: number) { this.categoryId = categoryId; this.take = take; - this.where__id__less_than = where__id__less_than; + this.whereIdLessThan = whereIdLessThan; } } diff --git a/BE/src/achievement/dto/paginate-achievement-response.spec.ts b/BE/src/achievement/dto/paginate-achievement-response.spec.ts index bc1d5763..dd9300e2 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.spec.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.spec.ts @@ -18,7 +18,7 @@ describe('PaginateAchievementResponse test', () => { paginateAchievementRequest, achievementResponses.slice(0, paginateAchievementRequest.take), ); - expect(response.next).toEqual({ take: 12, where__id__less_than: 88 }); + expect(response.next).toEqual({ take: 12, whereIdLessThan: 88 }); }); test('다음 페이지 네이션 요청을 위한 정보를 가지고 있는 next을 생성한다. - categoryId 필터링 추가', () => { const paginateAchievementRequest = new PaginateAchievementRequest(); @@ -31,7 +31,7 @@ describe('PaginateAchievementResponse test', () => { expect(response.next).toEqual({ categoryId: 1, take: 12, - where__id__less_than: 88, + whereIdLessThan: 88, }); }); }); diff --git a/BE/src/achievement/dto/paginate-achievement-response.ts b/BE/src/achievement/dto/paginate-achievement-response.ts index c550957d..73a59e66 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.ts @@ -38,11 +38,11 @@ export class PaginateAchievementResponse { if (!paginateAchievementRequest[key]) { continue; } - if (key !== 'where__id__less_than') { + if (key !== 'whereIdLessThan') { next[key] = paginateAchievementRequest[key]; } } - next.where__id__less_than = parseInt(last.id.toString()); + next.whereIdLessThan = parseInt(last.id.toString()); } return next ?? null; diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts index 72f2dbe0..370a9449 100644 --- a/BE/src/achievement/entities/achievement.repository.ts +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -3,24 +3,20 @@ import { TransactionalRepository } from '../../config/transaction-manager/transa import { AchievementEntity } from './achievement.entity'; import { Achievement } from '../domain/achievement.domain'; import { FindOptionsWhere, LessThan } from 'typeorm'; +import { Next } from '../index'; -export interface AchievementPaginationOption { - categoryId: number; - where__id__less_than?: number; - take: number; -} @CustomRepository(AchievementEntity) export class AchievementRepository extends TransactionalRepository { async findAll( userId: number, - achievementPaginationOption: AchievementPaginationOption, + achievementPaginationOption: Next, ): Promise { const where: FindOptionsWhere = { user: { id: userId }, category: { id: achievementPaginationOption.categoryId }, }; - if (achievementPaginationOption.where__id__less_than) { - where.id = LessThan(achievementPaginationOption.where__id__less_than); + if (achievementPaginationOption.whereIdLessThan) { + where.id = LessThan(achievementPaginationOption.whereIdLessThan); } const achievementEntities = await this.repository.find({ where, diff --git a/BE/src/achievement/index.ts b/BE/src/achievement/index.ts index c7776d2b..f8243e60 100644 --- a/BE/src/achievement/index.ts +++ b/BE/src/achievement/index.ts @@ -1,5 +1,5 @@ export interface Next { - where__id__less_than?: number; + whereIdLessThan?: number; take?: number; categoryId?: number; } From 3cc392647df47506686b62a00b62b92f3d4c9c6d Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 19:37:40 +0900 Subject: [PATCH 050/188] =?UTF-8?q?[BE]=20fix:=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=ED=83=80=EC=9E=85=EC=9D=84=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../achievement/entities/achievement.repository.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/BE/src/achievement/entities/achievement.repository.spec.ts b/BE/src/achievement/entities/achievement.repository.spec.ts index ab730373..33769b10 100644 --- a/BE/src/achievement/entities/achievement.repository.spec.ts +++ b/BE/src/achievement/entities/achievement.repository.spec.ts @@ -6,10 +6,7 @@ import { typeOrmModuleOptions } from '../../config/typeorm'; import { configServiceModuleOptions } from '../../config/config'; import { DataSource } from 'typeorm'; import { transactionTest } from '../../../test/common/transaction-test'; -import { - AchievementPaginationOption, - AchievementRepository, -} from './achievement.repository'; +import { AchievementRepository } from './achievement.repository'; import { UsersFixture } from '../../../test/user/users-fixture'; import { UsersTestModule } from '../../../test/user/users-test.module'; import { CategoryRepository } from '../../category/entities/category.repository'; @@ -18,6 +15,7 @@ import { AchievementTestModule } from '../../../test/achievement/achievement-tes import { AchievementFixture } from '../../../test/achievement/achievement-fixture'; import { CategoryTestModule } from '../../../test/category/category-test.module'; import { Achievement } from '../domain/achievement.domain'; +import { Next } from '../index'; describe('AchievementRepository test', () => { let achievementRepository: AchievementRepository; @@ -92,7 +90,7 @@ describe('AchievementRepository test', () => { } // when - const achievementPaginationOption: AchievementPaginationOption = { + const achievementPaginationOption: Next = { categoryId: category.id, take: 12, }; From a345e3e5095475acbad7f8ed41a590f157bf40c0 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 19:53:55 +0900 Subject: [PATCH 051/188] =?UTF-8?q?[iOS]=20refactor:=20AlertFactory=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Design -> Presentaion/Common --- .../Sources/Presentation/Common}/AlertFactory.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename iOS/moti/moti/{Design/Sources/Design => Presentation/Sources/Presentation/Common}/AlertFactory.swift (100%) diff --git a/iOS/moti/moti/Design/Sources/Design/AlertFactory.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift similarity index 100% rename from iOS/moti/moti/Design/Sources/Design/AlertFactory.swift rename to iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift From a17b920d3576a9bacde4b3b2d4d2e10ed63eeebf Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 20:05:36 +0900 Subject: [PATCH 052/188] =?UTF-8?q?[iOS]=20refactor:=20ok=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20title=EC=9D=84=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cancel 은 "취소" 로 변경 - message 필드 삭제 - okTitle 추가 --- .../Presentation/Common/AlertFactory.swift | 16 ++++++++-------- .../Presentation/Home/HomeViewController.swift | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift index a7ca4871..0374b209 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift @@ -10,14 +10,14 @@ import UIKit public enum AlertFactory { public static func makeNormalAlert( title: String? = nil, - message: String? = nil, + okTitle: String? = "OK", okAction: @escaping () -> Void ) -> UIAlertController { - let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert) - let okAlert = UIAlertAction(title: "OK", style: .default) { _ in + let alertVC = UIAlertController(title: title, message: nil, preferredStyle: .alert) + let okAlert = UIAlertAction(title: okTitle, style: .default) { _ in okAction() } - let cancelAlert = UIAlertAction(title: "cancel", style: .cancel) + let cancelAlert = UIAlertAction(title: "취소", style: .cancel) alertVC.addAction(cancelAlert) alertVC.addAction(okAlert) @@ -26,15 +26,15 @@ public enum AlertFactory { public static func makeTextFieldAlert( title: String? = nil, - message: String? = nil, + okTitle: String? = "OK", placeholder: String? = nil, okAction: @escaping (String?) -> Void ) -> UIAlertController { - let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert) - let okAlert = UIAlertAction(title: "OK", style: .default) { _ in + let alertVC = UIAlertController(title: title, message: nil, preferredStyle: .alert) + let okAlert = UIAlertAction(title: okTitle, style: .default) { _ in okAction(alertVC.textFields?[0].text) } - let cancelAlert = UIAlertAction(title: "cancel", style: .cancel) + let cancelAlert = UIAlertAction(title: "취소", style: .cancel) alertVC.addAction(cancelAlert) alertVC.addAction(okAlert) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 786a325d..b773b86f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -50,9 +50,9 @@ final class HomeViewController: BaseViewController { @objc private func showAlert() { let alertVC = AlertFactory.makeTextFieldAlert( - title: "추가할 카테고리 이름을 정해주세요.", - message: "ex) 다이어트", - placeholder: "다이어트" + title: "추가할 카테고리 이름을 입력하세요.", + okTitle: "생성", + placeholder: "카테고리 이름은 최대 10글자입니다." ) { (text) in Logger.debug(text) } From d1d12b1ecf1afa6c4ba651c1f2e6ad6f63d20668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Mon, 20 Nov 2023 19:01:08 +0900 Subject: [PATCH 053/188] =?UTF-8?q?[iOS]=20feat:=20CaptureResultVC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moti/Application/AppCoordinator.swift | 2 +- .../Sources/Design/UIFont+Extension.swift | 1 + .../Capture/CaptureCoordinator.swift | 22 +++-- .../Presentation/Capture/CaptureView.swift | 1 - .../Capture/CaptureViewController.swift | 26 ++---- .../CaptureResultCoordinator.swift | 54 ++++++++++++ .../CaptureResult/CaptureResultView.swift | 88 +++++++++++++++++++ .../CaptureResultViewController.swift | 57 ++++++++++++ 8 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift diff --git a/iOS/moti/moti/Application/AppCoordinator.swift b/iOS/moti/moti/Application/AppCoordinator.swift index 7880568a..31e25329 100644 --- a/iOS/moti/moti/Application/AppCoordinator.swift +++ b/iOS/moti/moti/Application/AppCoordinator.swift @@ -24,7 +24,7 @@ final class AppCoordinator: Coordinator { } func start() { - moveLaunchViewController() + moveHomeViewController() } private func moveLaunchViewController() { diff --git a/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift b/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift index 576485c2..241c2f7e 100644 --- a/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift +++ b/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift @@ -12,6 +12,7 @@ public extension UIFont { static let small = UIFont.systemFont(ofSize: 12) static let medium = UIFont.systemFont(ofSize: 14) static let large = UIFont.systemFont(ofSize: 24) + static let largeBold = UIFont.boldSystemFont(ofSize: 24) static let xlarge = UIFont.systemFont(ofSize: 36) static let xlargeBold = UIFont.boldSystemFont(ofSize: 36) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 268cd7c0..abf54e18 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -23,22 +23,32 @@ final class CaptureCoordinator: Coordinator { func start() { let captureVC = CaptureViewController() + captureVC.delegate = self captureVC.coordinator = self + captureVC.navigationItem.leftBarButtonItem = UIBarButtonItem( title: "취소", style: .plain, target: self, action: #selector(cancelButtonAction) ) - let navVC = UINavigationController(rootViewController: captureVC) - navVC.modalPresentationStyle = .fullScreen - navigationController.present(navVC, animated: true) + navigationController.isNavigationBarHidden = false + navigationController.pushViewController(captureVC, animated: true) + } + + private func moveCaptureResultViewController(imageData: Data) { + let captureResultCoordinator = CaptureResultCoordinator(navigationController, self) + captureResultCoordinator.start(resultImageData: imageData) + childCoordinators.append(captureResultCoordinator) } @objc func cancelButtonAction() { + navigationController.isNavigationBarHidden = true finish() } - - func finish(animated: Bool = true) { - parentCoordinator?.dismiss(child: self, animated: animated) +} + +extension CaptureCoordinator: CaptureViewControllerDelegate { + func didCapture(imageData: Data) { + moveCaptureResultViewController(imageData: imageData) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index fec20009..f9ad8a31 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -110,7 +110,6 @@ final class CaptureView: UIView { .height(equalTo: preview.widthAnchor) .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) .horizontal(equalTo: safeAreaLayoutGuide) - // PreviewLayer를 Preview 에 넣기 previewLayer.backgroundColor = UIColor.primaryGray.cgColor diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index f8d356d7..f772045f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -10,9 +10,14 @@ import Core import AVFoundation import Design +protocol CaptureViewControllerDelegate: AnyObject { + func didCapture(imageData: Data) +} + final class CaptureViewController: BaseViewController { // MARK: - Properties + weak var delegate: CaptureViewControllerDelegate? weak var coordinator: CaptureCoordinator? // Capture Session @@ -100,6 +105,7 @@ final class CaptureViewController: BaseViewController { #if targetEnvironment(simulator) // Simulator Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") + delegate?.didCapture(imageData: .init()) #else // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 // - speed: 약간의 노이즈 감소만이 적용 @@ -123,24 +129,8 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { // 카메라 세션 끊기, 끊지 않으면 여러번 사진 찍기 가능 session?.stopRunning() - guard let data = photo.fileDataRepresentation(), - let image = UIImage(data: data) else { return } - - #if DEBUG - Logger.debug("이미지 사이즈: \(image.size)") - Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") - Logger.debug("Crop 사이즈: \(layoutView.preview.bounds)") - #endif - - layoutView.updatePreview(with: cropImage(image: image, rect: layoutView.preview.bounds)) - } - - private func cropImage(image: UIImage, rect: CGRect) -> UIImage { - guard let imageRef = image.cgImage?.cropping(to: rect) else { - return image - } + guard let data = photo.fileDataRepresentation() else { return } - let croppedImage = UIImage(cgImage: imageRef) - return croppedImage + delegate?.didCapture(imageData: data) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift new file mode 100644 index 00000000..ec3636a6 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift @@ -0,0 +1,54 @@ +// +// CaptureResultCoordinator.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit +import Core + +final class CaptureResultCoordinator: Coordinator { + var parentCoordinator: Coordinator? + var childCoordinators: [Coordinator] = [] + var navigationController: UINavigationController + + init( + _ navigationController: UINavigationController, + _ parentCoordinator: Coordinator? + ) { + self.navigationController = navigationController + self.parentCoordinator = parentCoordinator + } + + func start() { + + } + + func start(resultImageData: Data) { + let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) + captureResultVC.coordinator = self + + captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "다시 촬영", style: .plain, target: self, + action: #selector(recaptureButtonAction) + ) + + captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonAction) + ) + + navigationController.pushViewController(captureResultVC, animated: false) + } + + @objc func recaptureButtonAction() { + finish(animated: false) + } + + @objc func doneButtonAction() { + finish(animated: false) + parentCoordinator?.finish(animated: true) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift new file mode 100644 index 00000000..1ae60a9e --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift @@ -0,0 +1,88 @@ +// +// CaptureResultView.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit +import Design +import Domain + +final class CaptureResultView: UIView { + + // MARK: - Views + private let resultImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .gray + return imageView + }() + + private let titleTextField = { + let textField = UITextField() + textField.font = .largeBold + return textField + }() + private let categoryButton = { + let button = UIButton(type: .system) + + button.setTitle("카테고리", for: .normal) + button.setTitleColor(.primaryDarkGray, for: .normal) + + return button + }() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + private func setupUI() { + setupResultImageView() + setupTitleTextField() + setupCategoryButton() + } + + func configure(image: UIImage, category: String? = nil, count: Int) { + resultImageView.image = image + + if let category { + titleTextField.placeholder = "\(category) \(count)회 성공" + categoryButton.setTitle(category, for: .normal) + } else { + titleTextField.placeholder = "\(count)회 성공" + } + } +} + +// MARK: - Setup +extension CaptureResultView { + private func setupResultImageView() { + addSubview(resultImageView) + resultImageView.atl + .height(equalTo: resultImageView.widthAnchor) + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) + .horizontal(equalTo: safeAreaLayoutGuide) + } + + private func setupTitleTextField() { + addSubview(titleTextField) + titleTextField.atl + .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .bottom(equalTo: resultImageView.topAnchor, constant: -20) + } + + private func setupCategoryButton() { + addSubview(categoryButton) + categoryButton.atl + .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .bottom(equalTo: titleTextField.topAnchor, constant: -5) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift new file mode 100644 index 00000000..bfd8103f --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift @@ -0,0 +1,57 @@ +// +// CaptureResultViewController.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit +import Core +import Design + +final class CaptureResultViewController: BaseViewController { + + // MARK: - Properties + weak var coordinator: CaptureResultCoordinator? + private let resultImageData: Data + + // MARK: - Init + init(resultImageData: Data) { + self.resultImageData = resultImageData + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycles + override func viewDidLoad() { + super.viewDidLoad() + + if let resultImage = convertDataToImage(resultImageData) { + layoutView.configure(image: resultImage, count: 10) + } else { + layoutView.configure(image: MotiImage.sample1, count: 10) + } + } + + private func convertDataToImage(_ data: Data) -> UIImage? { + guard let image = UIImage(data: data) else { return nil } + + #if DEBUG + Logger.debug("이미지 사이즈: \(image.size)") + Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") + #endif + return image + } + + private func cropImage(image: UIImage, rect: CGRect) -> UIImage { + guard let imageRef = image.cgImage?.cropping(to: rect) else { + return image + } + + let croppedImage = UIImage(cgImage: imageRef) + return croppedImage + } +} From 0bb6348c530164ae39d5fb0f90ea9d8e954c1cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Mon, 20 Nov 2023 19:04:38 +0900 Subject: [PATCH 054/188] =?UTF-8?q?[iOS]=20feat:=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=EB=B0=94=20=EC=88=A8=EA=B8=B0?= =?UTF-8?q?=EB=8A=94=20=ED=83=80=EC=9D=B4=EB=B0=8D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Capture/CaptureCoordinator.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index abf54e18..4e935403 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -31,8 +31,11 @@ final class CaptureCoordinator: Coordinator { action: #selector(cancelButtonAction) ) - navigationController.isNavigationBarHidden = false navigationController.pushViewController(captureVC, animated: true) + + // 화면 이동한 뒤 네비게이션바 보이기 + // 화면 이동하기 전에 네비게이션바를 보여주면 잔상이 남음 + navigationController.isNavigationBarHidden = false } private func moveCaptureResultViewController(imageData: Data) { From f7038b681f4872d903902fd13db85cf8663fd051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Mon, 20 Nov 2023 20:52:59 +0900 Subject: [PATCH 055/188] =?UTF-8?q?[iOS]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=84=A0=ED=83=9D=20pickerView=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CaptureResult/CaptureResultView.swift | 59 ++++++++++++++++--- .../CaptureResultViewController.swift | 44 +++++++++++++- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift index 1ae60a9e..c95aac70 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift @@ -24,7 +24,8 @@ final class CaptureResultView: UIView { textField.font = .largeBold return textField }() - private let categoryButton = { + + let categoryButton = { let button = UIButton(type: .system) button.setTitle("카테고리", for: .normal) @@ -33,6 +34,20 @@ final class CaptureResultView: UIView { return button }() + let categoryPickerView = { + let pickerView = UIPickerView() + pickerView.backgroundColor = .primaryGray + pickerView.isHidden = true + return pickerView + }() + + let selectDoneButton = { + let button = UIButton(type: .system) + button.setTitle("완료", for: .normal) + button.isHidden = true + return button + }() + // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) @@ -44,12 +59,6 @@ final class CaptureResultView: UIView { setupUI() } - private func setupUI() { - setupResultImageView() - setupTitleTextField() - setupCategoryButton() - } - func configure(image: UIImage, category: String? = nil, count: Int) { resultImageView.image = image @@ -60,10 +69,31 @@ final class CaptureResultView: UIView { titleTextField.placeholder = "\(count)회 성공" } } + + func update(category: String) { + categoryButton.setTitle(category, for: .normal) + } + + func showCategoryPicker() { + categoryPickerView.isHidden = false + selectDoneButton.isHidden = false + } + + func hideCategoryPicker() { + categoryPickerView.isHidden = true + selectDoneButton.isHidden = true + } } // MARK: - Setup extension CaptureResultView { + private func setupUI() { + setupResultImageView() + setupTitleTextField() + setupCategoryButton() + setupCategoryPickerView() + } + private func setupResultImageView() { addSubview(resultImageView) resultImageView.atl @@ -75,7 +105,7 @@ extension CaptureResultView { private func setupTitleTextField() { addSubview(titleTextField) titleTextField.atl - .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) .bottom(equalTo: resultImageView.topAnchor, constant: -20) } @@ -85,4 +115,17 @@ extension CaptureResultView { .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) .bottom(equalTo: titleTextField.topAnchor, constant: -5) } + + private func setupCategoryPickerView() { + addSubview(categoryPickerView) + addSubview(selectDoneButton) + categoryPickerView.atl + .height(constant: 150) + .horizontal(equalTo: safeAreaLayoutGuide) + .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor) + + selectDoneButton.atl + .right(equalTo: categoryPickerView.rightAnchor, constant: -10) + .top(equalTo: categoryPickerView.topAnchor, constant: 10) + } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift index bfd8103f..610ec8ea 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift @@ -10,10 +10,12 @@ import Core import Design final class CaptureResultViewController: BaseViewController { - // MARK: - Properties weak var coordinator: CaptureResultCoordinator? + + // TODO: ViewModel로 변환 private let resultImageData: Data + private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] // MARK: - Init init(resultImageData: Data) { @@ -28,6 +30,8 @@ final class CaptureResultViewController: BaseViewController { // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() + setupPickerView() + addTarget() if let resultImage = convertDataToImage(resultImageData) { layoutView.configure(image: resultImage, count: 10) @@ -36,6 +40,24 @@ final class CaptureResultViewController: BaseViewController { } } + private func setupPickerView() { + layoutView.categoryPickerView.delegate = self + layoutView.categoryPickerView.dataSource = self + } + + private func addTarget() { + layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) + layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) + } + + @objc func showPicker() { + layoutView.showCategoryPicker() + } + + @objc func donePicker() { + layoutView.hideCategoryPicker() + } + private func convertDataToImage(_ data: Data) -> UIImage? { guard let image = UIImage(data: data) else { return nil } @@ -55,3 +77,23 @@ final class CaptureResultViewController: BaseViewController { return croppedImage } } + +extension CaptureResultViewController: UIPickerViewDelegate { + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + layoutView.update(category: categories[row]) + } +} + +extension CaptureResultViewController: UIPickerViewDataSource { + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return categories.count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return categories[row] + } +} From b0b33e22a033c58290e4a581f9a085f647b12674 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 21:08:31 +0900 Subject: [PATCH 056/188] =?UTF-8?q?[iOS]=20refactor:=20HomeViewModel=20act?= =?UTF-8?q?ion,=20state=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetch (카테고리, 달성기록) - setup dataSource (카테고리, 달성기록) --- .../Home/HomeViewController.swift | 9 +++--- .../Presentation/Home/HomeViewModel.swift | 28 ++++++++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index b773b86f..4299a325 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -8,7 +8,6 @@ import UIKit import Combine import Core -import Design final class HomeViewController: BaseViewController { @@ -33,8 +32,8 @@ final class HomeViewController: BaseViewController { setupAchievementDataSource() setupCategoryDataSource() - try? viewModel.fetchAchievementList() - viewModel.fetchCategories() + viewModel.action(.fetchAchievementList) + viewModel.action(.fetchCategories) // TODO: 카테고리 리스트 API를 받았을 때 실행시켜야 함. 지금은 임시로 0.1초 후에 실행 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { @@ -92,7 +91,7 @@ final class HomeViewController: BaseViewController { } let diffableDataSource = HomeViewModel.AchievementDataSource(dataSource: dataSource) - viewModel.setupAchievementDataSource(diffableDataSource) + viewModel.action(.setupAchievementDataSource(dataSource: diffableDataSource)) } private func setupCategoryDataSource() { @@ -107,7 +106,7 @@ final class HomeViewController: BaseViewController { ) let diffableDataSource = HomeViewModel.CategoryDataSource(dataSource: dataSource) - viewModel.setupCategoryDataSource(diffableDataSource) + viewModel.action(.setupCategoryDataSource(dataSource: diffableDataSource)) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 0c94ea4b..f75a761e 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -9,6 +9,13 @@ import Foundation import Domain final class HomeViewModel { + enum HomeViewModelAction { + case setupCategoryDataSource(dataSource: CategoryDataSource) + case setupAchievementDataSource(dataSource: AchievementDataSource) + case fetchCategories + case fetchAchievementList + } + typealias AchievementDataSource = ListDiffableDataSource typealias CategoryDataSource = ListDiffableDataSource @@ -47,19 +54,32 @@ final class HomeViewModel { self.fetchAchievementListUseCase = fetchAchievementListUseCase } - func setupCategoryDataSource(_ dataSource: CategoryDataSource) { + func action(_ action: HomeViewModelAction) { + switch action { + case .setupCategoryDataSource(let dataSource): + setupCategoryDataSource(dataSource) + case .setupAchievementDataSource(let dataSource): + setupAchievementDataSource(dataSource) + case .fetchCategories: + fetchCategories() + case .fetchAchievementList: + try? fetchAchievementList() + } + } + + private func setupCategoryDataSource(_ dataSource: CategoryDataSource) { self.categoryDataSource = dataSource } - func setupAchievementDataSource(_ dataSource: AchievementDataSource) { + private func setupAchievementDataSource(_ dataSource: AchievementDataSource) { self.achievementDataSource = dataSource } - func fetchCategories() { + private func fetchCategories() { categoryDataSource?.update(data: categories) } - func fetchAchievementList() throws { + private func fetchAchievementList() throws { Task { achievements = try await fetchAchievementListUseCase.execute() achievementDataSource?.update(data: achievements) From eecf3996b34ab8f518360f142b918e617c76ecf6 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 21:43:42 +0900 Subject: [PATCH 057/188] =?UTF-8?q?[iOS]=20refactor:=20dataSource=20action?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Home/HomeViewController.swift | 4 ++-- .../Sources/Presentation/Home/HomeViewModel.swift | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 4299a325..b9274072 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -91,7 +91,7 @@ final class HomeViewController: BaseViewController { } let diffableDataSource = HomeViewModel.AchievementDataSource(dataSource: dataSource) - viewModel.action(.setupAchievementDataSource(dataSource: diffableDataSource)) + viewModel.setupAchievementDataSource(diffableDataSource) } private func setupCategoryDataSource() { @@ -106,7 +106,7 @@ final class HomeViewController: BaseViewController { ) let diffableDataSource = HomeViewModel.CategoryDataSource(dataSource: dataSource) - viewModel.action(.setupCategoryDataSource(dataSource: diffableDataSource)) + viewModel.setupCategoryDataSource(diffableDataSource) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index f75a761e..13bc1d96 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -10,8 +10,6 @@ import Domain final class HomeViewModel { enum HomeViewModelAction { - case setupCategoryDataSource(dataSource: CategoryDataSource) - case setupAchievementDataSource(dataSource: AchievementDataSource) case fetchCategories case fetchAchievementList } @@ -56,10 +54,6 @@ final class HomeViewModel { func action(_ action: HomeViewModelAction) { switch action { - case .setupCategoryDataSource(let dataSource): - setupCategoryDataSource(dataSource) - case .setupAchievementDataSource(let dataSource): - setupAchievementDataSource(dataSource) case .fetchCategories: fetchCategories() case .fetchAchievementList: @@ -67,11 +61,11 @@ final class HomeViewModel { } } - private func setupCategoryDataSource(_ dataSource: CategoryDataSource) { + func setupCategoryDataSource(_ dataSource: CategoryDataSource) { self.categoryDataSource = dataSource } - private func setupAchievementDataSource(_ dataSource: AchievementDataSource) { + func setupAchievementDataSource(_ dataSource: AchievementDataSource) { self.achievementDataSource = dataSource } From f6f6f80314fd1fdf90479f86b5b0583d7e1dd37a Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 22:06:39 +0900 Subject: [PATCH 058/188] =?UTF-8?q?[iOS]=20refactor:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC,=20=EB=8B=AC=EC=84=B1=EA=B8=B0=EB=A1=9D=20ac?= =?UTF-8?q?tion=20=EB=AC=B6=EA=B8=B0=20&=20State=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/HomeViewController.swift | 14 ++++++- .../Presentation/Home/HomeViewModel.swift | 37 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index b9274072..0d1a7d52 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -14,6 +14,7 @@ final class HomeViewController: BaseViewController { // MARK: - Properties weak var coordinator: HomeCoordinator? private let viewModel: HomeViewModel + private var cancellables: Set = [] init(viewModel: HomeViewModel) { self.viewModel = viewModel @@ -28,12 +29,12 @@ final class HomeViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() addTargets() + bind() setupAchievementDataSource() setupCategoryDataSource() - viewModel.action(.fetchAchievementList) - viewModel.action(.fetchCategories) + viewModel.action(.fetchData) // TODO: 카테고리 리스트 API를 받았을 때 실행시켜야 함. 지금은 임시로 0.1초 후에 실행 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { @@ -43,6 +44,15 @@ final class HomeViewController: BaseViewController { } // MARK: - Methods + private func bind() { + viewModel.$achievementState + .sink { state in + // state 에 따른 뷰 처리 - 스켈레톤 뷰, fetch 에러 뷰 등 + Logger.debug(state) + } + .store(in: &cancellables) + } + private func addTargets() { layoutView.catergoryAddButton.addTarget(self, action: #selector(showAlert), for: .touchUpInside) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 13bc1d96..48baba95 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -10,8 +10,21 @@ import Domain final class HomeViewModel { enum HomeViewModelAction { - case fetchCategories - case fetchAchievementList + case fetchData + } + + enum CategoryState { + case none + case loading + case finish + case error(message: String) + } + + enum AchievementState { + case none + case loading + case finish + case error(message: String) } typealias AchievementDataSource = ListDiffableDataSource @@ -46,6 +59,9 @@ final class HomeViewModel { ] private var achievements: [Achievement] = [] + @Published private(set) var categoryState: CategoryState = .none + @Published private(set) var achievementState: AchievementState = .none + init( fetchAchievementListUseCase: FetchAchievementListUseCase ) { @@ -54,10 +70,9 @@ final class HomeViewModel { func action(_ action: HomeViewModelAction) { switch action { - case .fetchCategories: + case .fetchData: fetchCategories() - case .fetchAchievementList: - try? fetchAchievementList() + fetchAchievementList() } } @@ -73,10 +88,16 @@ final class HomeViewModel { categoryDataSource?.update(data: categories) } - private func fetchAchievementList() throws { + private func fetchAchievementList() { Task { - achievements = try await fetchAchievementListUseCase.execute() - achievementDataSource?.update(data: achievements) + do { + achievementState = .loading + achievements = try await fetchAchievementListUseCase.execute() + achievementState = .finish + achievementDataSource?.update(data: achievements) + } catch { + achievementState = .error(message: error.localizedDescription) + } } } } From 4563382be8f8732ec6e8486d152dc1b0dd616033 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 22:27:05 +0900 Subject: [PATCH 059/188] =?UTF-8?q?[BE]=20fix:=20categoryId=EA=B0=80=200?= =?UTF-8?q?=EC=9D=BC=EB=95=8C=20=EC=A0=84=EC=B2=B4=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EC=9D=B4=20=EC=A1=B0=ED=9A=8C=20=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/achievement.repository.spec.ts | 38 ++++++++++++++++++- .../entities/achievement.repository.ts | 8 ++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/BE/src/achievement/entities/achievement.repository.spec.ts b/BE/src/achievement/entities/achievement.repository.spec.ts index 33769b10..f4c2fd5d 100644 --- a/BE/src/achievement/entities/achievement.repository.spec.ts +++ b/BE/src/achievement/entities/achievement.repository.spec.ts @@ -15,7 +15,7 @@ import { AchievementTestModule } from '../../../test/achievement/achievement-tes import { AchievementFixture } from '../../../test/achievement/achievement-fixture'; import { CategoryTestModule } from '../../../test/category/category-test.module'; import { Achievement } from '../domain/achievement.domain'; -import { Next } from '../index'; +import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; describe('AchievementRepository test', () => { let achievementRepository: AchievementRepository; @@ -90,7 +90,7 @@ describe('AchievementRepository test', () => { } // when - const achievementPaginationOption: Next = { + const achievementPaginationOption: PaginateAchievementRequest = { categoryId: category.id, take: 12, }; @@ -103,4 +103,38 @@ describe('AchievementRepository test', () => { expect(findAll.length).toEqual(10); }); }); + + test('카테고리 ID가 0인 경우에는 모든 달성 기록을 조회한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category_1 = await categoryFixture.getCategory(user, 'ABC'); + const category_2 = await categoryFixture.getCategory(user, 'DEF'); + + const achievements = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category_1), + ); + } + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category_2), + ); + } + + // when + const achievementPaginationOption: PaginateAchievementRequest = { + categoryId: 0, + take: 12, + }; + const findAll = await achievementRepository.findAll( + user.id, + achievementPaginationOption, + ); + + // then + expect(findAll.length).toEqual(12); + }); + }); }); diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts index 370a9449..7e200fc3 100644 --- a/BE/src/achievement/entities/achievement.repository.ts +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -3,18 +3,20 @@ import { TransactionalRepository } from '../../config/transaction-manager/transa import { AchievementEntity } from './achievement.entity'; import { Achievement } from '../domain/achievement.domain'; import { FindOptionsWhere, LessThan } from 'typeorm'; -import { Next } from '../index'; +import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; @CustomRepository(AchievementEntity) export class AchievementRepository extends TransactionalRepository { async findAll( userId: number, - achievementPaginationOption: Next, + achievementPaginationOption: PaginateAchievementRequest, ): Promise { const where: FindOptionsWhere = { user: { id: userId }, - category: { id: achievementPaginationOption.categoryId }, }; + if (achievementPaginationOption.categoryId !== 0) { + where.category = { id: achievementPaginationOption.categoryId }; + } if (achievementPaginationOption.whereIdLessThan) { where.id = LessThan(achievementPaginationOption.whereIdLessThan); } From 24f0a07195366faae6c6869dba1602181884a91a Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 22:27:41 +0900 Subject: [PATCH 060/188] =?UTF-8?q?[BE]=20fix:=20default=20take=20?= =?UTF-8?q?=EA=B0=92=EC=9D=84=2012=EB=A1=9C=20=EC=A7=80=EC=A0=95=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=EC=9D=84=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/dto/paginate-achievement-request.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BE/src/achievement/dto/paginate-achievement-request.ts b/BE/src/achievement/dto/paginate-achievement-request.ts index 4f1f1c7d..d1de2e96 100644 --- a/BE/src/achievement/dto/paginate-achievement-request.ts +++ b/BE/src/achievement/dto/paginate-achievement-request.ts @@ -10,14 +10,18 @@ export class PaginateAchievementRequest { @IsNumber() @IsOptional() @ApiProperty({ description: 'take' }) - take: number = 12; + take: number; @IsNumber() @IsOptional() @ApiProperty({ description: 'categoryId' }) categoryId: number; - constructor(categoryId?: number, take?: number, whereIdLessThan?: number) { + constructor( + categoryId?: number, + take: number = 12, + whereIdLessThan?: number, + ) { this.categoryId = categoryId; this.take = take; this.whereIdLessThan = whereIdLessThan; From 8c3034d6399c42498e9d6a85394a1394a976b608 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Mon, 20 Nov 2023 22:36:50 +0900 Subject: [PATCH 061/188] =?UTF-8?q?[iOS]=20refactor:=20HomeViewModel=20cas?= =?UTF-8?q?e=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchData -> launch - none 제거, loading -> initial --- .../Presentation/Home/HomeViewController.swift | 2 +- .../Sources/Presentation/Home/HomeViewModel.swift | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 0d1a7d52..45e17e1e 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -34,7 +34,7 @@ final class HomeViewController: BaseViewController { setupAchievementDataSource() setupCategoryDataSource() - viewModel.action(.fetchData) + viewModel.action(.launch) // TODO: 카테고리 리스트 API를 받았을 때 실행시켜야 함. 지금은 임시로 0.1초 후에 실행 DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 48baba95..691b81c0 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -10,19 +10,17 @@ import Domain final class HomeViewModel { enum HomeViewModelAction { - case fetchData + case launch } enum CategoryState { - case none - case loading + case initial case finish case error(message: String) } enum AchievementState { - case none - case loading + case initial case finish case error(message: String) } @@ -59,8 +57,8 @@ final class HomeViewModel { ] private var achievements: [Achievement] = [] - @Published private(set) var categoryState: CategoryState = .none - @Published private(set) var achievementState: AchievementState = .none + @Published private(set) var categoryState: CategoryState = .initial + @Published private(set) var achievementState: AchievementState = .initial init( fetchAchievementListUseCase: FetchAchievementListUseCase @@ -70,7 +68,7 @@ final class HomeViewModel { func action(_ action: HomeViewModelAction) { switch action { - case .fetchData: + case .launch: fetchCategories() fetchAchievementList() } @@ -91,7 +89,6 @@ final class HomeViewModel { private func fetchAchievementList() { Task { do { - achievementState = .loading achievements = try await fetchAchievementListUseCase.execute() achievementState = .finish achievementDataSource?.update(data: achievements) From 6f25bd2fe6186c7663f266be6701671bc3291f98 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 22:43:29 +0900 Subject: [PATCH 062/188] =?UTF-8?q?[BE]=20fix:=20categoryId=EA=B0=80=20nul?= =?UTF-8?q?lable=ED=95=9C=20=EA=B0=92=EC=9D=B4=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/entities/achievement.repository.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts index 7e200fc3..7ecb121a 100644 --- a/BE/src/achievement/entities/achievement.repository.ts +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -14,7 +14,10 @@ export class AchievementRepository extends TransactionalRepository = { user: { id: userId }, }; - if (achievementPaginationOption.categoryId !== 0) { + if ( + achievementPaginationOption.categoryId && + achievementPaginationOption.categoryId !== 0 + ) { where.category = { id: achievementPaginationOption.categoryId }; } if (achievementPaginationOption.whereIdLessThan) { From d53bf36dda6fd835542f2bee95c41d1185c5c027 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 22:47:39 +0900 Subject: [PATCH 063/188] =?UTF-8?q?[BE]=20test:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20ID=EB=A5=BC=20=EB=84=A3=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EA=B2=BD=EC=9A=B0=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EB=8B=AC=EC=84=B1=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EC=A1=B0=ED=9A=8C=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/achievement.repository.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/BE/src/achievement/entities/achievement.repository.spec.ts b/BE/src/achievement/entities/achievement.repository.spec.ts index f4c2fd5d..50705414 100644 --- a/BE/src/achievement/entities/achievement.repository.spec.ts +++ b/BE/src/achievement/entities/achievement.repository.spec.ts @@ -137,4 +137,36 @@ describe('AchievementRepository test', () => { expect(findAll.length).toEqual(12); }); }); + + test('카테고리 ID를 넣지 않은 경우에도 모든 달성 기록을 조회한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category_1 = await categoryFixture.getCategory(user, 'ABC'); + const category_2 = await categoryFixture.getCategory(user, 'DEF'); + + const achievements = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category_1), + ); + } + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category_2), + ); + } + + // when + const achievementPaginationOption: PaginateAchievementRequest = + new PaginateAchievementRequest(); + const findAll = await achievementRepository.findAll( + user.id, + achievementPaginationOption, + ); + + // then + expect(findAll.length).toEqual(12); + }); + }); }); From 07bfde3efa5c8924968bce8d7ee7fe436c2fc114 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Mon, 20 Nov 2023 22:52:16 +0900 Subject: [PATCH 064/188] =?UTF-8?q?[BE]=20refactor:=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20=EA=B0=84=EA=B2=B0=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/entities/achievement.repository.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts index 7ecb121a..014a99f6 100644 --- a/BE/src/achievement/entities/achievement.repository.ts +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -14,10 +14,7 @@ export class AchievementRepository extends TransactionalRepository = { user: { id: userId }, }; - if ( - achievementPaginationOption.categoryId && - achievementPaginationOption.categoryId !== 0 - ) { + if (achievementPaginationOption?.categoryId !== 0) { where.category = { id: achievementPaginationOption.categoryId }; } if (achievementPaginationOption.whereIdLessThan) { From c8a57048f1a8ff03c1fc6efd03bf14e9d5c2792c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 11:57:02 +0900 Subject: [PATCH 065/188] =?UTF-8?q?[iOS]=20feat:=20=EC=BA=A1=EC=B2=98?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureCoordinator.swift | 54 +++-- .../Presentation/Capture/CaptureView.swift | 54 +++-- .../Capture/CaptureViewController.swift | 89 ++++++-- .../CaptureResultCoordinator.swift | 104 ++++----- .../CaptureResult/CaptureResultView.swift | 4 +- .../CaptureResultViewController.swift | 208 ++++++++++-------- .../Presentation/Common/AchievementView.swift | 157 +++++++++++++ .../Common/InputTextViewController.swift | 42 ++++ 8 files changed, 508 insertions(+), 204 deletions(-) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 4e935403..119c2397 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -12,6 +12,7 @@ final class CaptureCoordinator: Coordinator { var parentCoordinator: Coordinator? var childCoordinators: [Coordinator] = [] var navigationController: UINavigationController + private var currentViewController: CaptureViewController? init( _ navigationController: UINavigationController, @@ -26,32 +27,57 @@ final class CaptureCoordinator: Coordinator { captureVC.delegate = self captureVC.coordinator = self - captureVC.navigationItem.leftBarButtonItem = UIBarButtonItem( + currentViewController = captureVC + + changeCaptureMode() + + let navVC = UINavigationController(rootViewController: captureVC) + navVC.modalPresentationStyle = .fullScreen + navigationController.present(navVC, animated: true) + } + + private func changeCaptureMode() { + guard let currentViewController = currentViewController else { return } + currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( title: "취소", style: .plain, target: self, action: #selector(cancelButtonAction) ) - - navigationController.pushViewController(captureVC, animated: true) - - // 화면 이동한 뒤 네비게이션바 보이기 - // 화면 이동하기 전에 네비게이션바를 보여주면 잔상이 남음 - navigationController.isNavigationBarHidden = false } - private func moveCaptureResultViewController(imageData: Data) { - let captureResultCoordinator = CaptureResultCoordinator(navigationController, self) - captureResultCoordinator.start(resultImageData: imageData) - childCoordinators.append(captureResultCoordinator) + private func changeEditMode() { + guard let currentViewController = currentViewController else { return } + currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "다시 촬영", style: .plain, target: self, + action: #selector(recaptureButtonAction) + ) + + currentViewController.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonAction) + ) } @objc func cancelButtonAction() { - navigationController.isNavigationBarHidden = true finish() } + + @objc func recaptureButtonAction() { + changeCaptureMode() + currentViewController?.startCapture() + } + + @objc func doneButtonAction() { + finish() + } + + func finish(animated: Bool = true) { + parentCoordinator?.dismiss(child: self, animated: true) + } } extension CaptureCoordinator: CaptureViewControllerDelegate { - func didCapture(imageData: Data) { - moveCaptureResultViewController(imageData: imageData) + func didCapture() { + changeEditMode() } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index f9ad8a31..05df1a64 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -27,14 +27,18 @@ final class CaptureView: UIView { } return previewLayer }() - let preview = UIView() + private let preview = { + let view = UIView() + view.backgroundColor = .primaryGray + return view + }() let captureButton = CaptureButton() // VC에서 액션을 달아주기 위해 private 제거 - private let resultImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.isHidden = true - return imageView + + let achievementView = { + let achievementView = AchievementView() + achievementView.isHidden = true + return achievementView }() override init(frame: CGRect) { @@ -55,17 +59,32 @@ final class CaptureView: UIView { } // MARK: - Methods - func updatePreview(with image: UIImage) { - resultImageView.isHidden = false - resultImageView.image = image - } - func updatePreviewLayer(session: AVCaptureSession) { - resultImageView.isHidden = true previewLayer.session = session + captureMode() + } + + func captureMode() { + preview.isHidden = false + achievementView.isHidden = true + + photoButton.isHidden = false + cameraSwitchingButton.isHidden = false + captureButton.isHidden = false + } + + func editMode(image: UIImage) { + preview.isHidden = true + achievementView.update(image: image) + achievementView.isHidden = false + + photoButton.isHidden = true + cameraSwitchingButton.isHidden = true + captureButton.isHidden = true } private func setupUI() { + setupAchievementView() setupPreview() setupCaptureButton() @@ -97,10 +116,11 @@ final class CaptureView: UIView { .left(equalTo: captureButton.rightAnchor, constant: 30) } - private func setupResultImageView() { - addSubview(resultImageView) - resultImageView.atl - .all(of: preview) + private func setupAchievementView() { + addSubview(achievementView) + achievementView.atl + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + .horizontal(equalTo: safeAreaLayoutGuide) } private func setupPreview() { @@ -108,7 +128,7 @@ final class CaptureView: UIView { addSubview(preview) preview.atl .height(equalTo: preview.widthAnchor) - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) + .top(equalTo: achievementView.resultImageView.topAnchor) .horizontal(equalTo: safeAreaLayoutGuide) // PreviewLayer를 Preview 에 넣기 diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index f772045f..d3d193cc 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -11,7 +11,7 @@ import AVFoundation import Design protocol CaptureViewControllerDelegate: AnyObject { - func didCapture(imageData: Data) + func didCapture() } final class CaptureViewController: BaseViewController { @@ -20,11 +20,14 @@ final class CaptureViewController: BaseViewController { weak var delegate: CaptureViewControllerDelegate? weak var coordinator: CaptureCoordinator? + private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] + private var bottomSheet = InputTextViewController() + // Capture Session private var session: AVCaptureSession? // Photo Output - private let output = AVCapturePhotoOutput() + private var output: AVCapturePhotoOutput? // MARK: - Life Cycles override func viewDidLoad() { @@ -40,7 +43,9 @@ final class CaptureViewController: BaseViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) Logger.debug("Session Stop Running") - session?.stopRunning() + if let session = session, session.isRunning { + session.stopRunning() + } } // MARK: - Methods @@ -80,17 +85,25 @@ final class CaptureViewController: BaseViewController { let input = try AVCaptureDeviceInput(device: device) if session.canAddInput(input) { session.addInput(input) + Logger.debug("Add AVCaptureDeviceInput") } - if session.canAddOutput(output) { + output = AVCapturePhotoOutput() + if let output = output, + session.canAddOutput(output) { session.addOutput(output) + Logger.debug("Add AVCapturePhotoOutput") } layoutView.updatePreviewLayer(session: session) DispatchQueue.global().async { - Logger.debug("Session Start Running") - session.startRunning() + if !session.isRunning { + Logger.debug("Session Start Running") + session.startRunning() + } else { + Logger.debug("Session Already Running") + } } self.session = session @@ -99,27 +112,33 @@ final class CaptureViewController: BaseViewController { } } + func startCapture() { + setupCamera() + layoutView.captureMode() + } + @objc private func didClickedShutterButton() { // 사진 찍기! #if targetEnvironment(simulator) - // Simulator - Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") - delegate?.didCapture(imageData: .init()) + // Simulator + Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") + delegate?.didCapture() + layoutView.editMode(image: MotiImage.sample1) #else - // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 - // - speed: 약간의 노이즈 감소만이 적용 - // - balanced: speed보다 약간 더 느리지만 더 나은 품질을 얻음 - // - quality: 현대 디바이스나 밝기에 따라 많은 시간을 사용하여 최상의 품질을 만듬 - - // 빠른 속도를 위해 speed를 사용하려 했지만 - // WWDC 2021 - Capture high-quality photos using video formats에서 speed보다 balanced를 추천 (기본이 balanced임) - // 만약 사진과 비디오가 동일하게 보여야 하면 speed를 사용 + // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 + // - speed: 약간의 노이즈 감소만이 적용 + // - balanced: speed보다 약간 더 느리지만 더 나은 품질을 얻음 + // - quality: 현대 디바이스나 밝기에 따라 많은 시간을 사용하여 최상의 품질을 만듬 - // Actual Device - let setting = AVCapturePhotoSettings() - setting.photoQualityPrioritization = .balanced - output.capturePhoto(with: setting, delegate: self) + // 빠른 속도를 위해 speed를 사용하려 했지만 + // WWDC 2021 - Capture high-quality photos using video formats에서 speed보다 balanced를 추천 (기본이 balanced임) + // 만약 사진과 비디오가 동일하게 보여야 하면 speed를 사용 + + // Actual Device + let setting = AVCapturePhotoSettings() + setting.photoQualityPrioritization = .balanced + output?.capturePhoto(with: setting, delegate: self) #endif } } @@ -127,10 +146,34 @@ final class CaptureViewController: BaseViewController { extension CaptureViewController: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { // 카메라 세션 끊기, 끊지 않으면 여러번 사진 찍기 가능 - session?.stopRunning() + if let session = session, session.isRunning { + session.stopRunning() + } guard let data = photo.fileDataRepresentation() else { return } - delegate?.didCapture(imageData: data) + if let image = convertDataToImage(data) { + delegate?.didCapture() + layoutView.editMode(image: image) + } + } + + private func convertDataToImage(_ data: Data) -> UIImage? { + guard let image = UIImage(data: data) else { return nil } + + #if DEBUG + Logger.debug("이미지 사이즈: \(image.size)") + Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") + #endif + return image + } + + private func cropImage(image: UIImage, rect: CGRect) -> UIImage { + guard let imageRef = image.cgImage?.cropping(to: rect) else { + return image + } + + let croppedImage = UIImage(cgImage: imageRef) + return croppedImage } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift index ec3636a6..1c50d72f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift @@ -1,54 +1,54 @@ +//// +//// CaptureResultCoordinator.swift +//// +//// +//// Created by 유정주 on 11/20/23. +//// // -// CaptureResultCoordinator.swift -// +//import UIKit +//import Core // -// Created by 유정주 on 11/20/23. -// - -import UIKit -import Core - -final class CaptureResultCoordinator: Coordinator { - var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - var navigationController: UINavigationController - - init( - _ navigationController: UINavigationController, - _ parentCoordinator: Coordinator? - ) { - self.navigationController = navigationController - self.parentCoordinator = parentCoordinator - } - - func start() { - - } - - func start(resultImageData: Data) { - let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) - captureResultVC.coordinator = self - - captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( - title: "다시 촬영", style: .plain, target: self, - action: #selector(recaptureButtonAction) - ) - - captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(doneButtonAction) - ) - - navigationController.pushViewController(captureResultVC, animated: false) - } - - @objc func recaptureButtonAction() { - finish(animated: false) - } - - @objc func doneButtonAction() { - finish(animated: false) - parentCoordinator?.finish(animated: true) - } -} +//final class CaptureResultCoordinator: Coordinator { +// var parentCoordinator: Coordinator? +// var childCoordinators: [Coordinator] = [] +// var navigationController: UINavigationController +// +// init( +// _ navigationController: UINavigationController, +// _ parentCoordinator: Coordinator? +// ) { +// self.navigationController = navigationController +// self.parentCoordinator = parentCoordinator +// } +// +// func start() { +// +// } +// +// func start(resultImageData: Data) { +// let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) +// captureResultVC.coordinator = self +// +// captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( +// title: "다시 촬영", style: .plain, target: self, +// action: #selector(recaptureButtonAction) +// ) +// +// captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( +// barButtonSystemItem: .done, +// target: self, +// action: #selector(doneButtonAction) +// ) +// +// navigationController.pushViewController(captureResultVC, animated: false) +// } +// +// @objc func recaptureButtonAction() { +// finish(animated: false) +// } +// +// @objc func doneButtonAction() { +// finish(animated: false) +// parentCoordinator?.finish(animated: true) +// } +//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift index c95aac70..33e61fe5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift @@ -119,10 +119,10 @@ extension CaptureResultView { private func setupCategoryPickerView() { addSubview(categoryPickerView) addSubview(selectDoneButton) + categoryPickerView.atl - .height(constant: 150) .horizontal(equalTo: safeAreaLayoutGuide) - .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor) + .bottom(equalTo: bottomAnchor) selectDoneButton.atl .right(equalTo: categoryPickerView.rightAnchor, constant: -10) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift index 610ec8ea..9fd88637 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift @@ -1,99 +1,115 @@ +//// +//// CaptureResultViewController.swift +//// +//// +//// Created by 유정주 on 11/20/23. +//// // -// CaptureResultViewController.swift -// +//import UIKit +//import Core +//import Design // -// Created by 유정주 on 11/20/23. +//final class CaptureResultViewController: BaseViewController { +// // MARK: - Properties +// weak var coordinator: CaptureResultCoordinator? +// +// // TODO: ViewModel로 변환 +// private let resultImageData: Data +// private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] +// private var bottomSheet = InputTextViewController() +// +// // MARK: - Init +// init(resultImageData: Data) { +// self.resultImageData = resultImageData +// super.init(nibName: nil, bundle: nil) +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// // MARK: - Life Cycles +// override func viewDidLoad() { +// super.viewDidLoad() +// setupPickerView() +// addTarget() +// +// if let resultImage = convertDataToImage(resultImageData) { +// layoutView.configure(image: resultImage, count: 10) +// } else { +// layoutView.configure(image: MotiImage.sample1, count: 10) +// } +// } +// +// override func viewIsAppearing(_ animated: Bool) { +// super.viewIsAppearing(animated) +// +// showBottomSheet() +// } +// +// override func viewWillDisappear(_ animated: Bool) { +// super.viewWillDisappear(animated) +// bottomSheet.dismiss(animated: true) +// } +// +// private func setupPickerView() { +// layoutView.categoryPickerView.delegate = self +// layoutView.categoryPickerView.dataSource = self +// } +// +// private func addTarget() { +// layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) +// layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) +// } +// +// @objc private func showPicker() { +// hideBottomSheet() +// layoutView.showCategoryPicker() +// } +// +// @objc private func donePicker() { +// layoutView.hideCategoryPicker() +// showBottomSheet() +// } +// +// private func showBottomSheet() { +// bottomSheet.modalPresentationStyle = .pageSheet +// +// if let sheet = bottomSheet.sheetPresentationController { +// sheet.detents = [.small(), .large()] +// sheet.prefersGrabberVisible = true +// sheet.prefersScrollingExpandsWhenScrolledToEdge = false +// sheet.selectedDetentIdentifier = .small +// sheet.largestUndimmedDetentIdentifier = .large +// } +// +// bottomSheet.isModalInPresentation = true +// present(bottomSheet, animated: true) +// } +// +// private func hideBottomSheet() { +// bottomSheet.dismiss(animated: true) +// } +// // - -import UIKit -import Core -import Design - -final class CaptureResultViewController: BaseViewController { - // MARK: - Properties - weak var coordinator: CaptureResultCoordinator? - - // TODO: ViewModel로 변환 - private let resultImageData: Data - private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] - - // MARK: - Init - init(resultImageData: Data) { - self.resultImageData = resultImageData - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Life Cycles - override func viewDidLoad() { - super.viewDidLoad() - setupPickerView() - addTarget() - - if let resultImage = convertDataToImage(resultImageData) { - layoutView.configure(image: resultImage, count: 10) - } else { - layoutView.configure(image: MotiImage.sample1, count: 10) - } - } - - private func setupPickerView() { - layoutView.categoryPickerView.delegate = self - layoutView.categoryPickerView.dataSource = self - } - - private func addTarget() { - layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) - layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) - } - - @objc func showPicker() { - layoutView.showCategoryPicker() - } - - @objc func donePicker() { - layoutView.hideCategoryPicker() - } - - private func convertDataToImage(_ data: Data) -> UIImage? { - guard let image = UIImage(data: data) else { return nil } - - #if DEBUG - Logger.debug("이미지 사이즈: \(image.size)") - Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") - #endif - return image - } - - private func cropImage(image: UIImage, rect: CGRect) -> UIImage { - guard let imageRef = image.cgImage?.cropping(to: rect) else { - return image - } - - let croppedImage = UIImage(cgImage: imageRef) - return croppedImage - } -} - -extension CaptureResultViewController: UIPickerViewDelegate { - func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { - layoutView.update(category: categories[row]) - } -} - -extension CaptureResultViewController: UIPickerViewDataSource { - func numberOfComponents(in pickerView: UIPickerView) -> Int { - return 1 - } - - func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { - return categories.count - } - - func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { - return categories[row] - } -} +//} +// +//extension CaptureResultViewController: UIPickerViewDelegate { +// func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { +// layoutView.update(category: categories[row]) +// } +//} +// +//extension CaptureResultViewController: UIPickerViewDataSource { +// func numberOfComponents(in pickerView: UIPickerView) -> Int { +// return 1 +// } +// +// func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { +// return categories.count +// } +// +// func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { +// return categories[row] +// } +//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift new file mode 100644 index 00000000..2d84be71 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -0,0 +1,157 @@ +// +// AchievementView.swift +// +// +// Created by 유정주 on 11/21/23. +// + +import UIKit + +final class AchievementView: UIView { + // MARK: - Views + let resultImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .gray + imageView.clipsToBounds = true + return imageView + }() + + private let titleTextField = { + let textField = UITextField() + textField.font = .largeBold + textField.placeholder = "도전 성공" + return textField + }() + + let categoryButton = { + let button = UIButton(type: .system) + + button.setTitle("카테고리", for: .normal) + button.setTitleColor(.primaryDarkGray, for: .normal) + button.setTitleColor(.label, for: .disabled) + + return button + }() + + let categoryPickerView = { + let pickerView = UIPickerView() + pickerView.backgroundColor = .primaryGray + pickerView.isHidden = true + return pickerView + }() + + let selectDoneButton = { + let button = UIButton(type: .system) + button.setTitle("완료", for: .normal) + button.isHidden = true + return button + }() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + func configureEdit(image: UIImage, category: String? = nil) { + resultImageView.image = image + + if let category { + titleTextField.placeholder = "\(category) 도전 성공" + categoryButton.setTitle(category, for: .normal) + } + } + + func configureReadOnly(image: UIImage, title: String, category: String) { + resultImageView.image = image + + titleTextField.text = title + titleTextField.isEnabled = false + + categoryButton.setTitle(category, for: .normal) + categoryButton.isEnabled = false + } + + func update(image: UIImage) { + resultImageView.image = image + } + + func update(title: String) { + titleTextField.text = title + } + + func update(category: String) { + categoryButton.setTitle(category, for: .normal) + } + + func editMode() { + titleTextField.isEnabled = true + categoryButton.isEnabled = true + } + + func readOnlyMode() { + titleTextField.isEnabled = false + categoryButton.isEnabled = false + } + + func showCategoryPicker() { + categoryPickerView.isHidden = false + selectDoneButton.isHidden = false + } + + func hideCategoryPicker() { + categoryPickerView.isHidden = true + selectDoneButton.isHidden = true + } +} + +// MARK: - Setup +extension AchievementView { + private func setupUI() { + setupCategoryButton() + setupTitleTextField() + setupResultImageView() + setupCategoryPickerView() + } + + private func setupCategoryButton() { + addSubview(categoryButton) + categoryButton.atl + .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + } + + private func setupTitleTextField() { + addSubview(titleTextField) + titleTextField.atl + .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) + .top(equalTo: categoryButton.bottomAnchor) + } + + private func setupResultImageView() { + addSubview(resultImageView) + resultImageView.atl + .horizontal(equalTo: safeAreaLayoutGuide) + .height(equalTo: resultImageView.widthAnchor) + .top(equalTo: titleTextField.bottomAnchor, constant: 10) + } + + private func setupCategoryPickerView() { + addSubview(categoryPickerView) + addSubview(selectDoneButton) + + categoryPickerView.atl + .horizontal(equalTo: safeAreaLayoutGuide) + .bottom(equalTo: bottomAnchor) + + selectDoneButton.atl + .right(equalTo: categoryPickerView.rightAnchor, constant: -10) + .top(equalTo: categoryPickerView.topAnchor, constant: 10) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift new file mode 100644 index 00000000..caf9f39f --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift @@ -0,0 +1,42 @@ +// +// InputTextViewController.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit + +final class InputTextViewController: UIViewController { + + private let textView = { + let textView = UITextView() + textView.font = .medium + return textView + }() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = textView.backgroundColor + view.addSubview(textView) + textView.atl + .top(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40) + .bottom(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + .horizontal(equalTo: view.safeAreaLayoutGuide, constant: 20) + } +} + +extension UISheetPresentationController.Detent.Identifier { + static let small = UISheetPresentationController.Detent.Identifier("small") +} + +extension UISheetPresentationController.Detent { + class func small() -> UISheetPresentationController.Detent { + if #available(iOS 16.0, *) { + return UISheetPresentationController.Detent.custom(identifier: .small) { 0.15 * $0.maximumDetentValue } + } else { + return .medium() + } + } +} From 3558e84164ec0fc5aed92f409a534292f8042260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 13:20:37 +0900 Subject: [PATCH 066/188] =?UTF-8?q?[iOS]=20feat:=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureCoordinator.swift | 2 + .../Presentation/Capture/CaptureView.swift | 54 ++++--- .../Capture/CaptureViewController.swift | 141 ++++++++++++++---- .../CaptureResult/CaptureResultView.swift | 131 ---------------- .../Presentation/Common/AchievementView.swift | 28 ++-- 5 files changed, 157 insertions(+), 199 deletions(-) delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 119c2397..26bbab78 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -42,6 +42,8 @@ final class CaptureCoordinator: Coordinator { title: "취소", style: .plain, target: self, action: #selector(cancelButtonAction) ) + + currentViewController.navigationItem.rightBarButtonItem = nil } private func changeEditMode() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 05df1a64..47518a08 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -12,10 +12,18 @@ import AVFoundation final class CaptureView: UIView { // MARK: - Views - private let photoButton = NormalButton(title: "앨범에서 선택", image: SymbolImage.photo) - private let cameraSwitchingButton = NormalButton(title: "카메라 전환", image: SymbolImage.iphone) - + // VC에서 액션을 달아주기 위해 private 제거 + let photoButton = NormalButton(title: "앨범에서 선택", image: SymbolImage.photo) + let cameraSwitchingButton = NormalButton(title: "카메라 전환", image: SymbolImage.iphone) + let captureButton = CaptureButton() + // Video Preview + private let preview = { + let view = UIView() + view.backgroundColor = .primaryGray + return view + }() + private let previewLayer = { let previewLayer = AVCaptureVideoPreviewLayer() previewLayer.videoGravity = .resizeAspectFill @@ -27,20 +35,15 @@ final class CaptureView: UIView { } return previewLayer }() - private let preview = { - let view = UIView() - view.backgroundColor = .primaryGray - return view - }() - - let captureButton = CaptureButton() // VC에서 액션을 달아주기 위해 private 제거 + // 편집 뷰 let achievementView = { let achievementView = AchievementView() achievementView.isHidden = true return achievementView }() + // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) setupUI() @@ -61,29 +64,31 @@ final class CaptureView: UIView { // MARK: - Methods func updatePreviewLayer(session: AVCaptureSession) { previewLayer.session = session - captureMode() } func captureMode() { preview.isHidden = false - achievementView.isHidden = true - photoButton.isHidden = false cameraSwitchingButton.isHidden = false captureButton.isHidden = false + + achievementView.isHidden = true } func editMode(image: UIImage) { preview.isHidden = true - achievementView.update(image: image) - achievementView.isHidden = false - photoButton.isHidden = true cameraSwitchingButton.isHidden = true captureButton.isHidden = true + + achievementView.isHidden = false + achievementView.configureEdit(image: image) } - - private func setupUI() { +} + +// MARK: - Setup +private extension CaptureView { + func setupUI() { setupAchievementView() setupPreview() @@ -92,7 +97,7 @@ final class CaptureView: UIView { setupCameraSwitchingButton() } - private func setupCaptureButton() { + func setupCaptureButton() { addSubview(captureButton) captureButton.atl .size(width: CaptureButton.defaultSize, height: CaptureButton.defaultSize) @@ -100,7 +105,7 @@ final class CaptureView: UIView { .bottom(equalTo: bottomAnchor, constant: -36) } - private func setupPhotoButton() { + func setupPhotoButton() { photoButton.setColor(.tabBarItemGray) addSubview(photoButton) photoButton.atl @@ -108,7 +113,7 @@ final class CaptureView: UIView { .right(equalTo: captureButton.leftAnchor, constant: -30) } - private func setupCameraSwitchingButton() { + func setupCameraSwitchingButton() { cameraSwitchingButton.setColor(.tabBarItemGray) addSubview(cameraSwitchingButton) cameraSwitchingButton.atl @@ -116,14 +121,15 @@ final class CaptureView: UIView { .left(equalTo: captureButton.rightAnchor, constant: 30) } - private func setupAchievementView() { + func setupAchievementView() { addSubview(achievementView) achievementView.atl - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + .top(equalTo: safeAreaLayoutGuide.topAnchor) + .bottom(equalTo: bottomAnchor) .horizontal(equalTo: safeAreaLayoutGuide) } - private func setupPreview() { + func setupPreview() { // 카메라 Preview addSubview(preview) preview.atl diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index d3d193cc..d7c63e9f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -32,6 +32,7 @@ final class CaptureViewController: BaseViewController { // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() + setupCategoryPickerView() addTargets() } @@ -42,17 +43,39 @@ final class CaptureViewController: BaseViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - Logger.debug("Session Stop Running") - if let session = session, session.isRunning { + if let session = session, + session.isRunning { + Logger.debug("Session Stop Running") session.stopRunning() } } + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + view.endEditing(true) + } + // MARK: - Methods private func addTargets() { layoutView.captureButton.addTarget(self, action: #selector(didClickedShutterButton), for: .touchUpInside) + layoutView.achievementView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) + layoutView.achievementView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) + } + + func startCapture() { + layoutView.achievementView.hideCategoryPicker() + hideBottomSheet() + setupCamera() + layoutView.captureMode() } + + func startEdit(image: UIImage) { + showBottomSheet() + layoutView.editMode(image: MotiImage.sample1) + } +} +// MARK: - Camera +extension CaptureViewController { private func checkCameraPermissions() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .notDetermined: // 첫 권한 요청 @@ -87,36 +110,31 @@ final class CaptureViewController: BaseViewController { session.addInput(input) Logger.debug("Add AVCaptureDeviceInput") } - - output = AVCapturePhotoOutput() - if let output = output, - session.canAddOutput(output) { - session.addOutput(output) - Logger.debug("Add AVCapturePhotoOutput") - } - - layoutView.updatePreviewLayer(session: session) - - DispatchQueue.global().async { - if !session.isRunning { - Logger.debug("Session Start Running") - session.startRunning() - } else { - Logger.debug("Session Already Running") - } - } - self.session = session - } catch { Logger.debug(error) } - } - - func startCapture() { - setupCamera() + + output = AVCapturePhotoOutput() + if let output = output, + session.canAddOutput(output) { + session.addOutput(output) + Logger.debug("Add AVCapturePhotoOutput") + } + + layoutView.updatePreviewLayer(session: session) layoutView.captureMode() + + DispatchQueue.global().async { + if !session.isRunning { + Logger.debug("Session Start Running") + session.startRunning() + } else { + Logger.debug("Session Already Running") + } + } + self.session = session } - + @objc private func didClickedShutterButton() { // 사진 찍기! @@ -124,7 +142,7 @@ final class CaptureViewController: BaseViewController { // Simulator Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") delegate?.didCapture() - layoutView.editMode(image: MotiImage.sample1) + startEdit(image: MotiImage.sample1) #else // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 // - speed: 약간의 노이즈 감소만이 적용 @@ -146,7 +164,8 @@ final class CaptureViewController: BaseViewController { extension CaptureViewController: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { // 카메라 세션 끊기, 끊지 않으면 여러번 사진 찍기 가능 - if let session = session, session.isRunning { + if let session = session, + session.isRunning { session.stopRunning() } @@ -154,7 +173,9 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { if let image = convertDataToImage(data) { delegate?.didCapture() - layoutView.editMode(image: image) + let rect = CGRect(origin: .zero, size: .init(width: 1000, height: 1000)) + let croppedImage = cropImage(image: image, rect: rect) + layoutView.editMode(image: croppedImage) } } @@ -177,3 +198,63 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { return croppedImage } } + +// MARK: - Bottom Sheet +private extension CaptureViewController { + func showBottomSheet() { + bottomSheet.modalPresentationStyle = .pageSheet + + if let sheet = bottomSheet.sheetPresentationController { + sheet.detents = [.small(), .large()] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.selectedDetentIdentifier = .small + sheet.largestUndimmedDetentIdentifier = .large + } + + bottomSheet.isModalInPresentation = true + present(bottomSheet, animated: true) + } + + func hideBottomSheet() { + bottomSheet.dismiss(animated: true) + } +} + +// MARK: - Category PickerView +extension CaptureViewController { + private func setupCategoryPickerView() { + layoutView.achievementView.categoryPickerView.delegate = self + layoutView.achievementView.categoryPickerView.dataSource = self + } + + @objc private func showPicker() { + hideBottomSheet() + layoutView.achievementView.showCategoryPicker() + } + + @objc private func donePicker() { + layoutView.achievementView.hideCategoryPicker() + showBottomSheet() + } +} + +extension CaptureViewController: UIPickerViewDelegate { + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + layoutView.achievementView.update(category: categories[row]) + } +} + +extension CaptureViewController: UIPickerViewDataSource { + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return categories.count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return categories[row] + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift deleted file mode 100644 index 33e61fe5..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// CaptureResultView.swift -// -// -// Created by 유정주 on 11/20/23. -// - -import UIKit -import Design -import Domain - -final class CaptureResultView: UIView { - - // MARK: - Views - private let resultImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.backgroundColor = .gray - return imageView - }() - - private let titleTextField = { - let textField = UITextField() - textField.font = .largeBold - return textField - }() - - let categoryButton = { - let button = UIButton(type: .system) - - button.setTitle("카테고리", for: .normal) - button.setTitleColor(.primaryDarkGray, for: .normal) - - return button - }() - - let categoryPickerView = { - let pickerView = UIPickerView() - pickerView.backgroundColor = .primaryGray - pickerView.isHidden = true - return pickerView - }() - - let selectDoneButton = { - let button = UIButton(type: .system) - button.setTitle("완료", for: .normal) - button.isHidden = true - return button - }() - - // MARK: - Init - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupUI() - } - - func configure(image: UIImage, category: String? = nil, count: Int) { - resultImageView.image = image - - if let category { - titleTextField.placeholder = "\(category) \(count)회 성공" - categoryButton.setTitle(category, for: .normal) - } else { - titleTextField.placeholder = "\(count)회 성공" - } - } - - func update(category: String) { - categoryButton.setTitle(category, for: .normal) - } - - func showCategoryPicker() { - categoryPickerView.isHidden = false - selectDoneButton.isHidden = false - } - - func hideCategoryPicker() { - categoryPickerView.isHidden = true - selectDoneButton.isHidden = true - } -} - -// MARK: - Setup -extension CaptureResultView { - private func setupUI() { - setupResultImageView() - setupTitleTextField() - setupCategoryButton() - setupCategoryPickerView() - } - - private func setupResultImageView() { - addSubview(resultImageView) - resultImageView.atl - .height(equalTo: resultImageView.widthAnchor) - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) - .horizontal(equalTo: safeAreaLayoutGuide) - } - - private func setupTitleTextField() { - addSubview(titleTextField) - titleTextField.atl - .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) - .bottom(equalTo: resultImageView.topAnchor, constant: -20) - } - - private func setupCategoryButton() { - addSubview(categoryButton) - categoryButton.atl - .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) - .bottom(equalTo: titleTextField.topAnchor, constant: -5) - } - - private func setupCategoryPickerView() { - addSubview(categoryPickerView) - addSubview(selectDoneButton) - - categoryPickerView.atl - .horizontal(equalTo: safeAreaLayoutGuide) - .bottom(equalTo: bottomAnchor) - - selectDoneButton.atl - .right(equalTo: categoryPickerView.rightAnchor, constant: -10) - .top(equalTo: categoryPickerView.topAnchor, constant: 10) - } -} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift index 2d84be71..d24b3ec2 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -68,15 +68,15 @@ final class AchievementView: UIView { } } - func configureReadOnly(image: UIImage, title: String, category: String) { - resultImageView.image = image - - titleTextField.text = title - titleTextField.isEnabled = false - - categoryButton.setTitle(category, for: .normal) - categoryButton.isEnabled = false - } +// func configureReadOnly(image: UIImage, title: String, category: String) { +// resultImageView.image = image +// +// titleTextField.text = title +// titleTextField.isEnabled = false +// +// categoryButton.setTitle(category, for: .normal) +// categoryButton.isEnabled = false +// } func update(image: UIImage) { resultImageView.image = image @@ -94,11 +94,11 @@ final class AchievementView: UIView { titleTextField.isEnabled = true categoryButton.isEnabled = true } - - func readOnlyMode() { - titleTextField.isEnabled = false - categoryButton.isEnabled = false - } +// +// func readOnlyMode() { +// titleTextField.isEnabled = false +// categoryButton.isEnabled = false +// } func showCategoryPicker() { categoryPickerView.isHidden = false From 48b5fc06ad12c87addce292d75fcfbb6151b7a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 13:28:09 +0900 Subject: [PATCH 067/188] =?UTF-8?q?[iOS]=20feat:=20CaptureResultVC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CaptureResultCoordinator.swift | 54 -------- .../CaptureResultViewController.swift | 115 ------------------ .../Presentation/Common/AchievementView.swift | 28 ++--- 3 files changed, 14 insertions(+), 183 deletions(-) delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift deleted file mode 100644 index 1c50d72f..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift +++ /dev/null @@ -1,54 +0,0 @@ -//// -//// CaptureResultCoordinator.swift -//// -//// -//// Created by 유정주 on 11/20/23. -//// -// -//import UIKit -//import Core -// -//final class CaptureResultCoordinator: Coordinator { -// var parentCoordinator: Coordinator? -// var childCoordinators: [Coordinator] = [] -// var navigationController: UINavigationController -// -// init( -// _ navigationController: UINavigationController, -// _ parentCoordinator: Coordinator? -// ) { -// self.navigationController = navigationController -// self.parentCoordinator = parentCoordinator -// } -// -// func start() { -// -// } -// -// func start(resultImageData: Data) { -// let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) -// captureResultVC.coordinator = self -// -// captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( -// title: "다시 촬영", style: .plain, target: self, -// action: #selector(recaptureButtonAction) -// ) -// -// captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( -// barButtonSystemItem: .done, -// target: self, -// action: #selector(doneButtonAction) -// ) -// -// navigationController.pushViewController(captureResultVC, animated: false) -// } -// -// @objc func recaptureButtonAction() { -// finish(animated: false) -// } -// -// @objc func doneButtonAction() { -// finish(animated: false) -// parentCoordinator?.finish(animated: true) -// } -//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift deleted file mode 100644 index 9fd88637..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift +++ /dev/null @@ -1,115 +0,0 @@ -//// -//// CaptureResultViewController.swift -//// -//// -//// Created by 유정주 on 11/20/23. -//// -// -//import UIKit -//import Core -//import Design -// -//final class CaptureResultViewController: BaseViewController { -// // MARK: - Properties -// weak var coordinator: CaptureResultCoordinator? -// -// // TODO: ViewModel로 변환 -// private let resultImageData: Data -// private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] -// private var bottomSheet = InputTextViewController() -// -// // MARK: - Init -// init(resultImageData: Data) { -// self.resultImageData = resultImageData -// super.init(nibName: nil, bundle: nil) -// } -// -// required init?(coder: NSCoder) { -// fatalError("init(coder:) has not been implemented") -// } -// -// // MARK: - Life Cycles -// override func viewDidLoad() { -// super.viewDidLoad() -// setupPickerView() -// addTarget() -// -// if let resultImage = convertDataToImage(resultImageData) { -// layoutView.configure(image: resultImage, count: 10) -// } else { -// layoutView.configure(image: MotiImage.sample1, count: 10) -// } -// } -// -// override func viewIsAppearing(_ animated: Bool) { -// super.viewIsAppearing(animated) -// -// showBottomSheet() -// } -// -// override func viewWillDisappear(_ animated: Bool) { -// super.viewWillDisappear(animated) -// bottomSheet.dismiss(animated: true) -// } -// -// private func setupPickerView() { -// layoutView.categoryPickerView.delegate = self -// layoutView.categoryPickerView.dataSource = self -// } -// -// private func addTarget() { -// layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) -// layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) -// } -// -// @objc private func showPicker() { -// hideBottomSheet() -// layoutView.showCategoryPicker() -// } -// -// @objc private func donePicker() { -// layoutView.hideCategoryPicker() -// showBottomSheet() -// } -// -// private func showBottomSheet() { -// bottomSheet.modalPresentationStyle = .pageSheet -// -// if let sheet = bottomSheet.sheetPresentationController { -// sheet.detents = [.small(), .large()] -// sheet.prefersGrabberVisible = true -// sheet.prefersScrollingExpandsWhenScrolledToEdge = false -// sheet.selectedDetentIdentifier = .small -// sheet.largestUndimmedDetentIdentifier = .large -// } -// -// bottomSheet.isModalInPresentation = true -// present(bottomSheet, animated: true) -// } -// -// private func hideBottomSheet() { -// bottomSheet.dismiss(animated: true) -// } -// -// -//} -// -//extension CaptureResultViewController: UIPickerViewDelegate { -// func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { -// layoutView.update(category: categories[row]) -// } -//} -// -//extension CaptureResultViewController: UIPickerViewDataSource { -// func numberOfComponents(in pickerView: UIPickerView) -> Int { -// return 1 -// } -// -// func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { -// return categories.count -// } -// -// func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { -// return categories[row] -// } -//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift index d24b3ec2..2d84be71 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -68,15 +68,15 @@ final class AchievementView: UIView { } } -// func configureReadOnly(image: UIImage, title: String, category: String) { -// resultImageView.image = image -// -// titleTextField.text = title -// titleTextField.isEnabled = false -// -// categoryButton.setTitle(category, for: .normal) -// categoryButton.isEnabled = false -// } + func configureReadOnly(image: UIImage, title: String, category: String) { + resultImageView.image = image + + titleTextField.text = title + titleTextField.isEnabled = false + + categoryButton.setTitle(category, for: .normal) + categoryButton.isEnabled = false + } func update(image: UIImage) { resultImageView.image = image @@ -94,11 +94,11 @@ final class AchievementView: UIView { titleTextField.isEnabled = true categoryButton.isEnabled = true } -// -// func readOnlyMode() { -// titleTextField.isEnabled = false -// categoryButton.isEnabled = false -// } + + func readOnlyMode() { + titleTextField.isEnabled = false + categoryButton.isEnabled = false + } func showCategoryPicker() { categoryPickerView.isHidden = false From c1de6340c8a47551951db450c4bf571fc07fcef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Mon, 20 Nov 2023 19:01:08 +0900 Subject: [PATCH 068/188] =?UTF-8?q?[iOS]=20feat:=20CaptureResultVC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moti/Application/AppCoordinator.swift | 2 +- .../Sources/Design/UIFont+Extension.swift | 1 + .../Capture/CaptureCoordinator.swift | 22 +++-- .../Presentation/Capture/CaptureView.swift | 1 - .../Capture/CaptureViewController.swift | 26 ++---- .../CaptureResultCoordinator.swift | 54 ++++++++++++ .../CaptureResult/CaptureResultView.swift | 88 +++++++++++++++++++ .../CaptureResultViewController.swift | 57 ++++++++++++ 8 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift diff --git a/iOS/moti/moti/Application/AppCoordinator.swift b/iOS/moti/moti/Application/AppCoordinator.swift index 7880568a..31e25329 100644 --- a/iOS/moti/moti/Application/AppCoordinator.swift +++ b/iOS/moti/moti/Application/AppCoordinator.swift @@ -24,7 +24,7 @@ final class AppCoordinator: Coordinator { } func start() { - moveLaunchViewController() + moveHomeViewController() } private func moveLaunchViewController() { diff --git a/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift b/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift index 576485c2..241c2f7e 100644 --- a/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift +++ b/iOS/moti/moti/Design/Sources/Design/UIFont+Extension.swift @@ -12,6 +12,7 @@ public extension UIFont { static let small = UIFont.systemFont(ofSize: 12) static let medium = UIFont.systemFont(ofSize: 14) static let large = UIFont.systemFont(ofSize: 24) + static let largeBold = UIFont.boldSystemFont(ofSize: 24) static let xlarge = UIFont.systemFont(ofSize: 36) static let xlargeBold = UIFont.boldSystemFont(ofSize: 36) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 268cd7c0..abf54e18 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -23,22 +23,32 @@ final class CaptureCoordinator: Coordinator { func start() { let captureVC = CaptureViewController() + captureVC.delegate = self captureVC.coordinator = self + captureVC.navigationItem.leftBarButtonItem = UIBarButtonItem( title: "취소", style: .plain, target: self, action: #selector(cancelButtonAction) ) - let navVC = UINavigationController(rootViewController: captureVC) - navVC.modalPresentationStyle = .fullScreen - navigationController.present(navVC, animated: true) + navigationController.isNavigationBarHidden = false + navigationController.pushViewController(captureVC, animated: true) + } + + private func moveCaptureResultViewController(imageData: Data) { + let captureResultCoordinator = CaptureResultCoordinator(navigationController, self) + captureResultCoordinator.start(resultImageData: imageData) + childCoordinators.append(captureResultCoordinator) } @objc func cancelButtonAction() { + navigationController.isNavigationBarHidden = true finish() } - - func finish(animated: Bool = true) { - parentCoordinator?.dismiss(child: self, animated: animated) +} + +extension CaptureCoordinator: CaptureViewControllerDelegate { + func didCapture(imageData: Data) { + moveCaptureResultViewController(imageData: imageData) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index fec20009..f9ad8a31 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -110,7 +110,6 @@ final class CaptureView: UIView { .height(equalTo: preview.widthAnchor) .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) .horizontal(equalTo: safeAreaLayoutGuide) - // PreviewLayer를 Preview 에 넣기 previewLayer.backgroundColor = UIColor.primaryGray.cgColor diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index f8d356d7..f772045f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -10,9 +10,14 @@ import Core import AVFoundation import Design +protocol CaptureViewControllerDelegate: AnyObject { + func didCapture(imageData: Data) +} + final class CaptureViewController: BaseViewController { // MARK: - Properties + weak var delegate: CaptureViewControllerDelegate? weak var coordinator: CaptureCoordinator? // Capture Session @@ -100,6 +105,7 @@ final class CaptureViewController: BaseViewController { #if targetEnvironment(simulator) // Simulator Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") + delegate?.didCapture(imageData: .init()) #else // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 // - speed: 약간의 노이즈 감소만이 적용 @@ -123,24 +129,8 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { // 카메라 세션 끊기, 끊지 않으면 여러번 사진 찍기 가능 session?.stopRunning() - guard let data = photo.fileDataRepresentation(), - let image = UIImage(data: data) else { return } - - #if DEBUG - Logger.debug("이미지 사이즈: \(image.size)") - Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") - Logger.debug("Crop 사이즈: \(layoutView.preview.bounds)") - #endif - - layoutView.updatePreview(with: cropImage(image: image, rect: layoutView.preview.bounds)) - } - - private func cropImage(image: UIImage, rect: CGRect) -> UIImage { - guard let imageRef = image.cgImage?.cropping(to: rect) else { - return image - } + guard let data = photo.fileDataRepresentation() else { return } - let croppedImage = UIImage(cgImage: imageRef) - return croppedImage + delegate?.didCapture(imageData: data) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift new file mode 100644 index 00000000..ec3636a6 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift @@ -0,0 +1,54 @@ +// +// CaptureResultCoordinator.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit +import Core + +final class CaptureResultCoordinator: Coordinator { + var parentCoordinator: Coordinator? + var childCoordinators: [Coordinator] = [] + var navigationController: UINavigationController + + init( + _ navigationController: UINavigationController, + _ parentCoordinator: Coordinator? + ) { + self.navigationController = navigationController + self.parentCoordinator = parentCoordinator + } + + func start() { + + } + + func start(resultImageData: Data) { + let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) + captureResultVC.coordinator = self + + captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "다시 촬영", style: .plain, target: self, + action: #selector(recaptureButtonAction) + ) + + captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonAction) + ) + + navigationController.pushViewController(captureResultVC, animated: false) + } + + @objc func recaptureButtonAction() { + finish(animated: false) + } + + @objc func doneButtonAction() { + finish(animated: false) + parentCoordinator?.finish(animated: true) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift new file mode 100644 index 00000000..1ae60a9e --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift @@ -0,0 +1,88 @@ +// +// CaptureResultView.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit +import Design +import Domain + +final class CaptureResultView: UIView { + + // MARK: - Views + private let resultImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .gray + return imageView + }() + + private let titleTextField = { + let textField = UITextField() + textField.font = .largeBold + return textField + }() + private let categoryButton = { + let button = UIButton(type: .system) + + button.setTitle("카테고리", for: .normal) + button.setTitleColor(.primaryDarkGray, for: .normal) + + return button + }() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + private func setupUI() { + setupResultImageView() + setupTitleTextField() + setupCategoryButton() + } + + func configure(image: UIImage, category: String? = nil, count: Int) { + resultImageView.image = image + + if let category { + titleTextField.placeholder = "\(category) \(count)회 성공" + categoryButton.setTitle(category, for: .normal) + } else { + titleTextField.placeholder = "\(count)회 성공" + } + } +} + +// MARK: - Setup +extension CaptureResultView { + private func setupResultImageView() { + addSubview(resultImageView) + resultImageView.atl + .height(equalTo: resultImageView.widthAnchor) + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) + .horizontal(equalTo: safeAreaLayoutGuide) + } + + private func setupTitleTextField() { + addSubview(titleTextField) + titleTextField.atl + .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .bottom(equalTo: resultImageView.topAnchor, constant: -20) + } + + private func setupCategoryButton() { + addSubview(categoryButton) + categoryButton.atl + .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .bottom(equalTo: titleTextField.topAnchor, constant: -5) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift new file mode 100644 index 00000000..bfd8103f --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift @@ -0,0 +1,57 @@ +// +// CaptureResultViewController.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit +import Core +import Design + +final class CaptureResultViewController: BaseViewController { + + // MARK: - Properties + weak var coordinator: CaptureResultCoordinator? + private let resultImageData: Data + + // MARK: - Init + init(resultImageData: Data) { + self.resultImageData = resultImageData + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycles + override func viewDidLoad() { + super.viewDidLoad() + + if let resultImage = convertDataToImage(resultImageData) { + layoutView.configure(image: resultImage, count: 10) + } else { + layoutView.configure(image: MotiImage.sample1, count: 10) + } + } + + private func convertDataToImage(_ data: Data) -> UIImage? { + guard let image = UIImage(data: data) else { return nil } + + #if DEBUG + Logger.debug("이미지 사이즈: \(image.size)") + Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") + #endif + return image + } + + private func cropImage(image: UIImage, rect: CGRect) -> UIImage { + guard let imageRef = image.cgImage?.cropping(to: rect) else { + return image + } + + let croppedImage = UIImage(cgImage: imageRef) + return croppedImage + } +} From 53ce44380f9aa01ff0be2da7dad7095cdf2484a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Mon, 20 Nov 2023 19:04:38 +0900 Subject: [PATCH 069/188] =?UTF-8?q?[iOS]=20feat:=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=EB=B0=94=20=EC=88=A8=EA=B8=B0?= =?UTF-8?q?=EB=8A=94=20=ED=83=80=EC=9D=B4=EB=B0=8D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Capture/CaptureCoordinator.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index abf54e18..4e935403 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -31,8 +31,11 @@ final class CaptureCoordinator: Coordinator { action: #selector(cancelButtonAction) ) - navigationController.isNavigationBarHidden = false navigationController.pushViewController(captureVC, animated: true) + + // 화면 이동한 뒤 네비게이션바 보이기 + // 화면 이동하기 전에 네비게이션바를 보여주면 잔상이 남음 + navigationController.isNavigationBarHidden = false } private func moveCaptureResultViewController(imageData: Data) { From bc5aabf249b7786d80085b2294878a06366cc3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Mon, 20 Nov 2023 20:52:59 +0900 Subject: [PATCH 070/188] =?UTF-8?q?[iOS]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=84=A0=ED=83=9D=20pickerView=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CaptureResult/CaptureResultView.swift | 59 ++++++++++++++++--- .../CaptureResultViewController.swift | 44 +++++++++++++- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift index 1ae60a9e..c95aac70 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift @@ -24,7 +24,8 @@ final class CaptureResultView: UIView { textField.font = .largeBold return textField }() - private let categoryButton = { + + let categoryButton = { let button = UIButton(type: .system) button.setTitle("카테고리", for: .normal) @@ -33,6 +34,20 @@ final class CaptureResultView: UIView { return button }() + let categoryPickerView = { + let pickerView = UIPickerView() + pickerView.backgroundColor = .primaryGray + pickerView.isHidden = true + return pickerView + }() + + let selectDoneButton = { + let button = UIButton(type: .system) + button.setTitle("완료", for: .normal) + button.isHidden = true + return button + }() + // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) @@ -44,12 +59,6 @@ final class CaptureResultView: UIView { setupUI() } - private func setupUI() { - setupResultImageView() - setupTitleTextField() - setupCategoryButton() - } - func configure(image: UIImage, category: String? = nil, count: Int) { resultImageView.image = image @@ -60,10 +69,31 @@ final class CaptureResultView: UIView { titleTextField.placeholder = "\(count)회 성공" } } + + func update(category: String) { + categoryButton.setTitle(category, for: .normal) + } + + func showCategoryPicker() { + categoryPickerView.isHidden = false + selectDoneButton.isHidden = false + } + + func hideCategoryPicker() { + categoryPickerView.isHidden = true + selectDoneButton.isHidden = true + } } // MARK: - Setup extension CaptureResultView { + private func setupUI() { + setupResultImageView() + setupTitleTextField() + setupCategoryButton() + setupCategoryPickerView() + } + private func setupResultImageView() { addSubview(resultImageView) resultImageView.atl @@ -75,7 +105,7 @@ extension CaptureResultView { private func setupTitleTextField() { addSubview(titleTextField) titleTextField.atl - .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) .bottom(equalTo: resultImageView.topAnchor, constant: -20) } @@ -85,4 +115,17 @@ extension CaptureResultView { .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) .bottom(equalTo: titleTextField.topAnchor, constant: -5) } + + private func setupCategoryPickerView() { + addSubview(categoryPickerView) + addSubview(selectDoneButton) + categoryPickerView.atl + .height(constant: 150) + .horizontal(equalTo: safeAreaLayoutGuide) + .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor) + + selectDoneButton.atl + .right(equalTo: categoryPickerView.rightAnchor, constant: -10) + .top(equalTo: categoryPickerView.topAnchor, constant: 10) + } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift index bfd8103f..610ec8ea 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift @@ -10,10 +10,12 @@ import Core import Design final class CaptureResultViewController: BaseViewController { - // MARK: - Properties weak var coordinator: CaptureResultCoordinator? + + // TODO: ViewModel로 변환 private let resultImageData: Data + private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] // MARK: - Init init(resultImageData: Data) { @@ -28,6 +30,8 @@ final class CaptureResultViewController: BaseViewController { // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() + setupPickerView() + addTarget() if let resultImage = convertDataToImage(resultImageData) { layoutView.configure(image: resultImage, count: 10) @@ -36,6 +40,24 @@ final class CaptureResultViewController: BaseViewController { } } + private func setupPickerView() { + layoutView.categoryPickerView.delegate = self + layoutView.categoryPickerView.dataSource = self + } + + private func addTarget() { + layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) + layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) + } + + @objc func showPicker() { + layoutView.showCategoryPicker() + } + + @objc func donePicker() { + layoutView.hideCategoryPicker() + } + private func convertDataToImage(_ data: Data) -> UIImage? { guard let image = UIImage(data: data) else { return nil } @@ -55,3 +77,23 @@ final class CaptureResultViewController: BaseViewController { return croppedImage } } + +extension CaptureResultViewController: UIPickerViewDelegate { + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + layoutView.update(category: categories[row]) + } +} + +extension CaptureResultViewController: UIPickerViewDataSource { + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return categories.count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return categories[row] + } +} From dbb19f41f5955c14ee92b9d11d68861a04ab70a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 11:57:02 +0900 Subject: [PATCH 071/188] =?UTF-8?q?[iOS]=20feat:=20=EC=BA=A1=EC=B2=98?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureCoordinator.swift | 54 +++-- .../Presentation/Capture/CaptureView.swift | 54 +++-- .../Capture/CaptureViewController.swift | 89 ++++++-- .../CaptureResultCoordinator.swift | 104 ++++----- .../CaptureResult/CaptureResultView.swift | 4 +- .../CaptureResultViewController.swift | 208 ++++++++++-------- .../Presentation/Common/AchievementView.swift | 157 +++++++++++++ .../Common/InputTextViewController.swift | 42 ++++ 8 files changed, 508 insertions(+), 204 deletions(-) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 4e935403..119c2397 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -12,6 +12,7 @@ final class CaptureCoordinator: Coordinator { var parentCoordinator: Coordinator? var childCoordinators: [Coordinator] = [] var navigationController: UINavigationController + private var currentViewController: CaptureViewController? init( _ navigationController: UINavigationController, @@ -26,32 +27,57 @@ final class CaptureCoordinator: Coordinator { captureVC.delegate = self captureVC.coordinator = self - captureVC.navigationItem.leftBarButtonItem = UIBarButtonItem( + currentViewController = captureVC + + changeCaptureMode() + + let navVC = UINavigationController(rootViewController: captureVC) + navVC.modalPresentationStyle = .fullScreen + navigationController.present(navVC, animated: true) + } + + private func changeCaptureMode() { + guard let currentViewController = currentViewController else { return } + currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( title: "취소", style: .plain, target: self, action: #selector(cancelButtonAction) ) - - navigationController.pushViewController(captureVC, animated: true) - - // 화면 이동한 뒤 네비게이션바 보이기 - // 화면 이동하기 전에 네비게이션바를 보여주면 잔상이 남음 - navigationController.isNavigationBarHidden = false } - private func moveCaptureResultViewController(imageData: Data) { - let captureResultCoordinator = CaptureResultCoordinator(navigationController, self) - captureResultCoordinator.start(resultImageData: imageData) - childCoordinators.append(captureResultCoordinator) + private func changeEditMode() { + guard let currentViewController = currentViewController else { return } + currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "다시 촬영", style: .plain, target: self, + action: #selector(recaptureButtonAction) + ) + + currentViewController.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonAction) + ) } @objc func cancelButtonAction() { - navigationController.isNavigationBarHidden = true finish() } + + @objc func recaptureButtonAction() { + changeCaptureMode() + currentViewController?.startCapture() + } + + @objc func doneButtonAction() { + finish() + } + + func finish(animated: Bool = true) { + parentCoordinator?.dismiss(child: self, animated: true) + } } extension CaptureCoordinator: CaptureViewControllerDelegate { - func didCapture(imageData: Data) { - moveCaptureResultViewController(imageData: imageData) + func didCapture() { + changeEditMode() } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index f9ad8a31..05df1a64 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -27,14 +27,18 @@ final class CaptureView: UIView { } return previewLayer }() - let preview = UIView() + private let preview = { + let view = UIView() + view.backgroundColor = .primaryGray + return view + }() let captureButton = CaptureButton() // VC에서 액션을 달아주기 위해 private 제거 - private let resultImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.isHidden = true - return imageView + + let achievementView = { + let achievementView = AchievementView() + achievementView.isHidden = true + return achievementView }() override init(frame: CGRect) { @@ -55,17 +59,32 @@ final class CaptureView: UIView { } // MARK: - Methods - func updatePreview(with image: UIImage) { - resultImageView.isHidden = false - resultImageView.image = image - } - func updatePreviewLayer(session: AVCaptureSession) { - resultImageView.isHidden = true previewLayer.session = session + captureMode() + } + + func captureMode() { + preview.isHidden = false + achievementView.isHidden = true + + photoButton.isHidden = false + cameraSwitchingButton.isHidden = false + captureButton.isHidden = false + } + + func editMode(image: UIImage) { + preview.isHidden = true + achievementView.update(image: image) + achievementView.isHidden = false + + photoButton.isHidden = true + cameraSwitchingButton.isHidden = true + captureButton.isHidden = true } private func setupUI() { + setupAchievementView() setupPreview() setupCaptureButton() @@ -97,10 +116,11 @@ final class CaptureView: UIView { .left(equalTo: captureButton.rightAnchor, constant: 30) } - private func setupResultImageView() { - addSubview(resultImageView) - resultImageView.atl - .all(of: preview) + private func setupAchievementView() { + addSubview(achievementView) + achievementView.atl + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + .horizontal(equalTo: safeAreaLayoutGuide) } private func setupPreview() { @@ -108,7 +128,7 @@ final class CaptureView: UIView { addSubview(preview) preview.atl .height(equalTo: preview.widthAnchor) - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) + .top(equalTo: achievementView.resultImageView.topAnchor) .horizontal(equalTo: safeAreaLayoutGuide) // PreviewLayer를 Preview 에 넣기 diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index f772045f..d3d193cc 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -11,7 +11,7 @@ import AVFoundation import Design protocol CaptureViewControllerDelegate: AnyObject { - func didCapture(imageData: Data) + func didCapture() } final class CaptureViewController: BaseViewController { @@ -20,11 +20,14 @@ final class CaptureViewController: BaseViewController { weak var delegate: CaptureViewControllerDelegate? weak var coordinator: CaptureCoordinator? + private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] + private var bottomSheet = InputTextViewController() + // Capture Session private var session: AVCaptureSession? // Photo Output - private let output = AVCapturePhotoOutput() + private var output: AVCapturePhotoOutput? // MARK: - Life Cycles override func viewDidLoad() { @@ -40,7 +43,9 @@ final class CaptureViewController: BaseViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) Logger.debug("Session Stop Running") - session?.stopRunning() + if let session = session, session.isRunning { + session.stopRunning() + } } // MARK: - Methods @@ -80,17 +85,25 @@ final class CaptureViewController: BaseViewController { let input = try AVCaptureDeviceInput(device: device) if session.canAddInput(input) { session.addInput(input) + Logger.debug("Add AVCaptureDeviceInput") } - if session.canAddOutput(output) { + output = AVCapturePhotoOutput() + if let output = output, + session.canAddOutput(output) { session.addOutput(output) + Logger.debug("Add AVCapturePhotoOutput") } layoutView.updatePreviewLayer(session: session) DispatchQueue.global().async { - Logger.debug("Session Start Running") - session.startRunning() + if !session.isRunning { + Logger.debug("Session Start Running") + session.startRunning() + } else { + Logger.debug("Session Already Running") + } } self.session = session @@ -99,27 +112,33 @@ final class CaptureViewController: BaseViewController { } } + func startCapture() { + setupCamera() + layoutView.captureMode() + } + @objc private func didClickedShutterButton() { // 사진 찍기! #if targetEnvironment(simulator) - // Simulator - Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") - delegate?.didCapture(imageData: .init()) + // Simulator + Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") + delegate?.didCapture() + layoutView.editMode(image: MotiImage.sample1) #else - // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 - // - speed: 약간의 노이즈 감소만이 적용 - // - balanced: speed보다 약간 더 느리지만 더 나은 품질을 얻음 - // - quality: 현대 디바이스나 밝기에 따라 많은 시간을 사용하여 최상의 품질을 만듬 - - // 빠른 속도를 위해 speed를 사용하려 했지만 - // WWDC 2021 - Capture high-quality photos using video formats에서 speed보다 balanced를 추천 (기본이 balanced임) - // 만약 사진과 비디오가 동일하게 보여야 하면 speed를 사용 + // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 + // - speed: 약간의 노이즈 감소만이 적용 + // - balanced: speed보다 약간 더 느리지만 더 나은 품질을 얻음 + // - quality: 현대 디바이스나 밝기에 따라 많은 시간을 사용하여 최상의 품질을 만듬 - // Actual Device - let setting = AVCapturePhotoSettings() - setting.photoQualityPrioritization = .balanced - output.capturePhoto(with: setting, delegate: self) + // 빠른 속도를 위해 speed를 사용하려 했지만 + // WWDC 2021 - Capture high-quality photos using video formats에서 speed보다 balanced를 추천 (기본이 balanced임) + // 만약 사진과 비디오가 동일하게 보여야 하면 speed를 사용 + + // Actual Device + let setting = AVCapturePhotoSettings() + setting.photoQualityPrioritization = .balanced + output?.capturePhoto(with: setting, delegate: self) #endif } } @@ -127,10 +146,34 @@ final class CaptureViewController: BaseViewController { extension CaptureViewController: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { // 카메라 세션 끊기, 끊지 않으면 여러번 사진 찍기 가능 - session?.stopRunning() + if let session = session, session.isRunning { + session.stopRunning() + } guard let data = photo.fileDataRepresentation() else { return } - delegate?.didCapture(imageData: data) + if let image = convertDataToImage(data) { + delegate?.didCapture() + layoutView.editMode(image: image) + } + } + + private func convertDataToImage(_ data: Data) -> UIImage? { + guard let image = UIImage(data: data) else { return nil } + + #if DEBUG + Logger.debug("이미지 사이즈: \(image.size)") + Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") + #endif + return image + } + + private func cropImage(image: UIImage, rect: CGRect) -> UIImage { + guard let imageRef = image.cgImage?.cropping(to: rect) else { + return image + } + + let croppedImage = UIImage(cgImage: imageRef) + return croppedImage } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift index ec3636a6..1c50d72f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift @@ -1,54 +1,54 @@ +//// +//// CaptureResultCoordinator.swift +//// +//// +//// Created by 유정주 on 11/20/23. +//// // -// CaptureResultCoordinator.swift -// +//import UIKit +//import Core // -// Created by 유정주 on 11/20/23. -// - -import UIKit -import Core - -final class CaptureResultCoordinator: Coordinator { - var parentCoordinator: Coordinator? - var childCoordinators: [Coordinator] = [] - var navigationController: UINavigationController - - init( - _ navigationController: UINavigationController, - _ parentCoordinator: Coordinator? - ) { - self.navigationController = navigationController - self.parentCoordinator = parentCoordinator - } - - func start() { - - } - - func start(resultImageData: Data) { - let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) - captureResultVC.coordinator = self - - captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( - title: "다시 촬영", style: .plain, target: self, - action: #selector(recaptureButtonAction) - ) - - captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(doneButtonAction) - ) - - navigationController.pushViewController(captureResultVC, animated: false) - } - - @objc func recaptureButtonAction() { - finish(animated: false) - } - - @objc func doneButtonAction() { - finish(animated: false) - parentCoordinator?.finish(animated: true) - } -} +//final class CaptureResultCoordinator: Coordinator { +// var parentCoordinator: Coordinator? +// var childCoordinators: [Coordinator] = [] +// var navigationController: UINavigationController +// +// init( +// _ navigationController: UINavigationController, +// _ parentCoordinator: Coordinator? +// ) { +// self.navigationController = navigationController +// self.parentCoordinator = parentCoordinator +// } +// +// func start() { +// +// } +// +// func start(resultImageData: Data) { +// let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) +// captureResultVC.coordinator = self +// +// captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( +// title: "다시 촬영", style: .plain, target: self, +// action: #selector(recaptureButtonAction) +// ) +// +// captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( +// barButtonSystemItem: .done, +// target: self, +// action: #selector(doneButtonAction) +// ) +// +// navigationController.pushViewController(captureResultVC, animated: false) +// } +// +// @objc func recaptureButtonAction() { +// finish(animated: false) +// } +// +// @objc func doneButtonAction() { +// finish(animated: false) +// parentCoordinator?.finish(animated: true) +// } +//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift index c95aac70..33e61fe5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift @@ -119,10 +119,10 @@ extension CaptureResultView { private func setupCategoryPickerView() { addSubview(categoryPickerView) addSubview(selectDoneButton) + categoryPickerView.atl - .height(constant: 150) .horizontal(equalTo: safeAreaLayoutGuide) - .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor) + .bottom(equalTo: bottomAnchor) selectDoneButton.atl .right(equalTo: categoryPickerView.rightAnchor, constant: -10) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift index 610ec8ea..9fd88637 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift @@ -1,99 +1,115 @@ +//// +//// CaptureResultViewController.swift +//// +//// +//// Created by 유정주 on 11/20/23. +//// // -// CaptureResultViewController.swift -// +//import UIKit +//import Core +//import Design // -// Created by 유정주 on 11/20/23. +//final class CaptureResultViewController: BaseViewController { +// // MARK: - Properties +// weak var coordinator: CaptureResultCoordinator? +// +// // TODO: ViewModel로 변환 +// private let resultImageData: Data +// private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] +// private var bottomSheet = InputTextViewController() +// +// // MARK: - Init +// init(resultImageData: Data) { +// self.resultImageData = resultImageData +// super.init(nibName: nil, bundle: nil) +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// // MARK: - Life Cycles +// override func viewDidLoad() { +// super.viewDidLoad() +// setupPickerView() +// addTarget() +// +// if let resultImage = convertDataToImage(resultImageData) { +// layoutView.configure(image: resultImage, count: 10) +// } else { +// layoutView.configure(image: MotiImage.sample1, count: 10) +// } +// } +// +// override func viewIsAppearing(_ animated: Bool) { +// super.viewIsAppearing(animated) +// +// showBottomSheet() +// } +// +// override func viewWillDisappear(_ animated: Bool) { +// super.viewWillDisappear(animated) +// bottomSheet.dismiss(animated: true) +// } +// +// private func setupPickerView() { +// layoutView.categoryPickerView.delegate = self +// layoutView.categoryPickerView.dataSource = self +// } +// +// private func addTarget() { +// layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) +// layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) +// } +// +// @objc private func showPicker() { +// hideBottomSheet() +// layoutView.showCategoryPicker() +// } +// +// @objc private func donePicker() { +// layoutView.hideCategoryPicker() +// showBottomSheet() +// } +// +// private func showBottomSheet() { +// bottomSheet.modalPresentationStyle = .pageSheet +// +// if let sheet = bottomSheet.sheetPresentationController { +// sheet.detents = [.small(), .large()] +// sheet.prefersGrabberVisible = true +// sheet.prefersScrollingExpandsWhenScrolledToEdge = false +// sheet.selectedDetentIdentifier = .small +// sheet.largestUndimmedDetentIdentifier = .large +// } +// +// bottomSheet.isModalInPresentation = true +// present(bottomSheet, animated: true) +// } +// +// private func hideBottomSheet() { +// bottomSheet.dismiss(animated: true) +// } +// // - -import UIKit -import Core -import Design - -final class CaptureResultViewController: BaseViewController { - // MARK: - Properties - weak var coordinator: CaptureResultCoordinator? - - // TODO: ViewModel로 변환 - private let resultImageData: Data - private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] - - // MARK: - Init - init(resultImageData: Data) { - self.resultImageData = resultImageData - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Life Cycles - override func viewDidLoad() { - super.viewDidLoad() - setupPickerView() - addTarget() - - if let resultImage = convertDataToImage(resultImageData) { - layoutView.configure(image: resultImage, count: 10) - } else { - layoutView.configure(image: MotiImage.sample1, count: 10) - } - } - - private func setupPickerView() { - layoutView.categoryPickerView.delegate = self - layoutView.categoryPickerView.dataSource = self - } - - private func addTarget() { - layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) - layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) - } - - @objc func showPicker() { - layoutView.showCategoryPicker() - } - - @objc func donePicker() { - layoutView.hideCategoryPicker() - } - - private func convertDataToImage(_ data: Data) -> UIImage? { - guard let image = UIImage(data: data) else { return nil } - - #if DEBUG - Logger.debug("이미지 사이즈: \(image.size)") - Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") - #endif - return image - } - - private func cropImage(image: UIImage, rect: CGRect) -> UIImage { - guard let imageRef = image.cgImage?.cropping(to: rect) else { - return image - } - - let croppedImage = UIImage(cgImage: imageRef) - return croppedImage - } -} - -extension CaptureResultViewController: UIPickerViewDelegate { - func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { - layoutView.update(category: categories[row]) - } -} - -extension CaptureResultViewController: UIPickerViewDataSource { - func numberOfComponents(in pickerView: UIPickerView) -> Int { - return 1 - } - - func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { - return categories.count - } - - func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { - return categories[row] - } -} +//} +// +//extension CaptureResultViewController: UIPickerViewDelegate { +// func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { +// layoutView.update(category: categories[row]) +// } +//} +// +//extension CaptureResultViewController: UIPickerViewDataSource { +// func numberOfComponents(in pickerView: UIPickerView) -> Int { +// return 1 +// } +// +// func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { +// return categories.count +// } +// +// func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { +// return categories[row] +// } +//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift new file mode 100644 index 00000000..2d84be71 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -0,0 +1,157 @@ +// +// AchievementView.swift +// +// +// Created by 유정주 on 11/21/23. +// + +import UIKit + +final class AchievementView: UIView { + // MARK: - Views + let resultImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .gray + imageView.clipsToBounds = true + return imageView + }() + + private let titleTextField = { + let textField = UITextField() + textField.font = .largeBold + textField.placeholder = "도전 성공" + return textField + }() + + let categoryButton = { + let button = UIButton(type: .system) + + button.setTitle("카테고리", for: .normal) + button.setTitleColor(.primaryDarkGray, for: .normal) + button.setTitleColor(.label, for: .disabled) + + return button + }() + + let categoryPickerView = { + let pickerView = UIPickerView() + pickerView.backgroundColor = .primaryGray + pickerView.isHidden = true + return pickerView + }() + + let selectDoneButton = { + let button = UIButton(type: .system) + button.setTitle("완료", for: .normal) + button.isHidden = true + return button + }() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + func configureEdit(image: UIImage, category: String? = nil) { + resultImageView.image = image + + if let category { + titleTextField.placeholder = "\(category) 도전 성공" + categoryButton.setTitle(category, for: .normal) + } + } + + func configureReadOnly(image: UIImage, title: String, category: String) { + resultImageView.image = image + + titleTextField.text = title + titleTextField.isEnabled = false + + categoryButton.setTitle(category, for: .normal) + categoryButton.isEnabled = false + } + + func update(image: UIImage) { + resultImageView.image = image + } + + func update(title: String) { + titleTextField.text = title + } + + func update(category: String) { + categoryButton.setTitle(category, for: .normal) + } + + func editMode() { + titleTextField.isEnabled = true + categoryButton.isEnabled = true + } + + func readOnlyMode() { + titleTextField.isEnabled = false + categoryButton.isEnabled = false + } + + func showCategoryPicker() { + categoryPickerView.isHidden = false + selectDoneButton.isHidden = false + } + + func hideCategoryPicker() { + categoryPickerView.isHidden = true + selectDoneButton.isHidden = true + } +} + +// MARK: - Setup +extension AchievementView { + private func setupUI() { + setupCategoryButton() + setupTitleTextField() + setupResultImageView() + setupCategoryPickerView() + } + + private func setupCategoryButton() { + addSubview(categoryButton) + categoryButton.atl + .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + } + + private func setupTitleTextField() { + addSubview(titleTextField) + titleTextField.atl + .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) + .top(equalTo: categoryButton.bottomAnchor) + } + + private func setupResultImageView() { + addSubview(resultImageView) + resultImageView.atl + .horizontal(equalTo: safeAreaLayoutGuide) + .height(equalTo: resultImageView.widthAnchor) + .top(equalTo: titleTextField.bottomAnchor, constant: 10) + } + + private func setupCategoryPickerView() { + addSubview(categoryPickerView) + addSubview(selectDoneButton) + + categoryPickerView.atl + .horizontal(equalTo: safeAreaLayoutGuide) + .bottom(equalTo: bottomAnchor) + + selectDoneButton.atl + .right(equalTo: categoryPickerView.rightAnchor, constant: -10) + .top(equalTo: categoryPickerView.topAnchor, constant: 10) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift new file mode 100644 index 00000000..caf9f39f --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift @@ -0,0 +1,42 @@ +// +// InputTextViewController.swift +// +// +// Created by 유정주 on 11/20/23. +// + +import UIKit + +final class InputTextViewController: UIViewController { + + private let textView = { + let textView = UITextView() + textView.font = .medium + return textView + }() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = textView.backgroundColor + view.addSubview(textView) + textView.atl + .top(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40) + .bottom(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + .horizontal(equalTo: view.safeAreaLayoutGuide, constant: 20) + } +} + +extension UISheetPresentationController.Detent.Identifier { + static let small = UISheetPresentationController.Detent.Identifier("small") +} + +extension UISheetPresentationController.Detent { + class func small() -> UISheetPresentationController.Detent { + if #available(iOS 16.0, *) { + return UISheetPresentationController.Detent.custom(identifier: .small) { 0.15 * $0.maximumDetentValue } + } else { + return .medium() + } + } +} From 6d70ae88e40abc1338aa97f846553545616ff114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 13:20:37 +0900 Subject: [PATCH 072/188] =?UTF-8?q?[iOS]=20feat:=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureCoordinator.swift | 2 + .../Presentation/Capture/CaptureView.swift | 54 ++++--- .../Capture/CaptureViewController.swift | 141 ++++++++++++++---- .../CaptureResult/CaptureResultView.swift | 131 ---------------- .../Presentation/Common/AchievementView.swift | 28 ++-- 5 files changed, 157 insertions(+), 199 deletions(-) delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 119c2397..26bbab78 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -42,6 +42,8 @@ final class CaptureCoordinator: Coordinator { title: "취소", style: .plain, target: self, action: #selector(cancelButtonAction) ) + + currentViewController.navigationItem.rightBarButtonItem = nil } private func changeEditMode() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 05df1a64..47518a08 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -12,10 +12,18 @@ import AVFoundation final class CaptureView: UIView { // MARK: - Views - private let photoButton = NormalButton(title: "앨범에서 선택", image: SymbolImage.photo) - private let cameraSwitchingButton = NormalButton(title: "카메라 전환", image: SymbolImage.iphone) - + // VC에서 액션을 달아주기 위해 private 제거 + let photoButton = NormalButton(title: "앨범에서 선택", image: SymbolImage.photo) + let cameraSwitchingButton = NormalButton(title: "카메라 전환", image: SymbolImage.iphone) + let captureButton = CaptureButton() + // Video Preview + private let preview = { + let view = UIView() + view.backgroundColor = .primaryGray + return view + }() + private let previewLayer = { let previewLayer = AVCaptureVideoPreviewLayer() previewLayer.videoGravity = .resizeAspectFill @@ -27,20 +35,15 @@ final class CaptureView: UIView { } return previewLayer }() - private let preview = { - let view = UIView() - view.backgroundColor = .primaryGray - return view - }() - - let captureButton = CaptureButton() // VC에서 액션을 달아주기 위해 private 제거 + // 편집 뷰 let achievementView = { let achievementView = AchievementView() achievementView.isHidden = true return achievementView }() + // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) setupUI() @@ -61,29 +64,31 @@ final class CaptureView: UIView { // MARK: - Methods func updatePreviewLayer(session: AVCaptureSession) { previewLayer.session = session - captureMode() } func captureMode() { preview.isHidden = false - achievementView.isHidden = true - photoButton.isHidden = false cameraSwitchingButton.isHidden = false captureButton.isHidden = false + + achievementView.isHidden = true } func editMode(image: UIImage) { preview.isHidden = true - achievementView.update(image: image) - achievementView.isHidden = false - photoButton.isHidden = true cameraSwitchingButton.isHidden = true captureButton.isHidden = true + + achievementView.isHidden = false + achievementView.configureEdit(image: image) } - - private func setupUI() { +} + +// MARK: - Setup +private extension CaptureView { + func setupUI() { setupAchievementView() setupPreview() @@ -92,7 +97,7 @@ final class CaptureView: UIView { setupCameraSwitchingButton() } - private func setupCaptureButton() { + func setupCaptureButton() { addSubview(captureButton) captureButton.atl .size(width: CaptureButton.defaultSize, height: CaptureButton.defaultSize) @@ -100,7 +105,7 @@ final class CaptureView: UIView { .bottom(equalTo: bottomAnchor, constant: -36) } - private func setupPhotoButton() { + func setupPhotoButton() { photoButton.setColor(.tabBarItemGray) addSubview(photoButton) photoButton.atl @@ -108,7 +113,7 @@ final class CaptureView: UIView { .right(equalTo: captureButton.leftAnchor, constant: -30) } - private func setupCameraSwitchingButton() { + func setupCameraSwitchingButton() { cameraSwitchingButton.setColor(.tabBarItemGray) addSubview(cameraSwitchingButton) cameraSwitchingButton.atl @@ -116,14 +121,15 @@ final class CaptureView: UIView { .left(equalTo: captureButton.rightAnchor, constant: 30) } - private func setupAchievementView() { + func setupAchievementView() { addSubview(achievementView) achievementView.atl - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + .top(equalTo: safeAreaLayoutGuide.topAnchor) + .bottom(equalTo: bottomAnchor) .horizontal(equalTo: safeAreaLayoutGuide) } - private func setupPreview() { + func setupPreview() { // 카메라 Preview addSubview(preview) preview.atl diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index d3d193cc..d7c63e9f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -32,6 +32,7 @@ final class CaptureViewController: BaseViewController { // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() + setupCategoryPickerView() addTargets() } @@ -42,17 +43,39 @@ final class CaptureViewController: BaseViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - Logger.debug("Session Stop Running") - if let session = session, session.isRunning { + if let session = session, + session.isRunning { + Logger.debug("Session Stop Running") session.stopRunning() } } + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + view.endEditing(true) + } + // MARK: - Methods private func addTargets() { layoutView.captureButton.addTarget(self, action: #selector(didClickedShutterButton), for: .touchUpInside) + layoutView.achievementView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) + layoutView.achievementView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) + } + + func startCapture() { + layoutView.achievementView.hideCategoryPicker() + hideBottomSheet() + setupCamera() + layoutView.captureMode() } + + func startEdit(image: UIImage) { + showBottomSheet() + layoutView.editMode(image: MotiImage.sample1) + } +} +// MARK: - Camera +extension CaptureViewController { private func checkCameraPermissions() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .notDetermined: // 첫 권한 요청 @@ -87,36 +110,31 @@ final class CaptureViewController: BaseViewController { session.addInput(input) Logger.debug("Add AVCaptureDeviceInput") } - - output = AVCapturePhotoOutput() - if let output = output, - session.canAddOutput(output) { - session.addOutput(output) - Logger.debug("Add AVCapturePhotoOutput") - } - - layoutView.updatePreviewLayer(session: session) - - DispatchQueue.global().async { - if !session.isRunning { - Logger.debug("Session Start Running") - session.startRunning() - } else { - Logger.debug("Session Already Running") - } - } - self.session = session - } catch { Logger.debug(error) } - } - - func startCapture() { - setupCamera() + + output = AVCapturePhotoOutput() + if let output = output, + session.canAddOutput(output) { + session.addOutput(output) + Logger.debug("Add AVCapturePhotoOutput") + } + + layoutView.updatePreviewLayer(session: session) layoutView.captureMode() + + DispatchQueue.global().async { + if !session.isRunning { + Logger.debug("Session Start Running") + session.startRunning() + } else { + Logger.debug("Session Already Running") + } + } + self.session = session } - + @objc private func didClickedShutterButton() { // 사진 찍기! @@ -124,7 +142,7 @@ final class CaptureViewController: BaseViewController { // Simulator Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") delegate?.didCapture() - layoutView.editMode(image: MotiImage.sample1) + startEdit(image: MotiImage.sample1) #else // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 // - speed: 약간의 노이즈 감소만이 적용 @@ -146,7 +164,8 @@ final class CaptureViewController: BaseViewController { extension CaptureViewController: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { // 카메라 세션 끊기, 끊지 않으면 여러번 사진 찍기 가능 - if let session = session, session.isRunning { + if let session = session, + session.isRunning { session.stopRunning() } @@ -154,7 +173,9 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { if let image = convertDataToImage(data) { delegate?.didCapture() - layoutView.editMode(image: image) + let rect = CGRect(origin: .zero, size: .init(width: 1000, height: 1000)) + let croppedImage = cropImage(image: image, rect: rect) + layoutView.editMode(image: croppedImage) } } @@ -177,3 +198,63 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { return croppedImage } } + +// MARK: - Bottom Sheet +private extension CaptureViewController { + func showBottomSheet() { + bottomSheet.modalPresentationStyle = .pageSheet + + if let sheet = bottomSheet.sheetPresentationController { + sheet.detents = [.small(), .large()] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.selectedDetentIdentifier = .small + sheet.largestUndimmedDetentIdentifier = .large + } + + bottomSheet.isModalInPresentation = true + present(bottomSheet, animated: true) + } + + func hideBottomSheet() { + bottomSheet.dismiss(animated: true) + } +} + +// MARK: - Category PickerView +extension CaptureViewController { + private func setupCategoryPickerView() { + layoutView.achievementView.categoryPickerView.delegate = self + layoutView.achievementView.categoryPickerView.dataSource = self + } + + @objc private func showPicker() { + hideBottomSheet() + layoutView.achievementView.showCategoryPicker() + } + + @objc private func donePicker() { + layoutView.achievementView.hideCategoryPicker() + showBottomSheet() + } +} + +extension CaptureViewController: UIPickerViewDelegate { + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + layoutView.achievementView.update(category: categories[row]) + } +} + +extension CaptureViewController: UIPickerViewDataSource { + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return categories.count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return categories[row] + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift deleted file mode 100644 index 33e61fe5..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultView.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// CaptureResultView.swift -// -// -// Created by 유정주 on 11/20/23. -// - -import UIKit -import Design -import Domain - -final class CaptureResultView: UIView { - - // MARK: - Views - private let resultImageView: UIImageView = { - let imageView = UIImageView() - imageView.contentMode = .scaleAspectFill - imageView.backgroundColor = .gray - return imageView - }() - - private let titleTextField = { - let textField = UITextField() - textField.font = .largeBold - return textField - }() - - let categoryButton = { - let button = UIButton(type: .system) - - button.setTitle("카테고리", for: .normal) - button.setTitleColor(.primaryDarkGray, for: .normal) - - return button - }() - - let categoryPickerView = { - let pickerView = UIPickerView() - pickerView.backgroundColor = .primaryGray - pickerView.isHidden = true - return pickerView - }() - - let selectDoneButton = { - let button = UIButton(type: .system) - button.setTitle("완료", for: .normal) - button.isHidden = true - return button - }() - - // MARK: - Init - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupUI() - } - - func configure(image: UIImage, category: String? = nil, count: Int) { - resultImageView.image = image - - if let category { - titleTextField.placeholder = "\(category) \(count)회 성공" - categoryButton.setTitle(category, for: .normal) - } else { - titleTextField.placeholder = "\(count)회 성공" - } - } - - func update(category: String) { - categoryButton.setTitle(category, for: .normal) - } - - func showCategoryPicker() { - categoryPickerView.isHidden = false - selectDoneButton.isHidden = false - } - - func hideCategoryPicker() { - categoryPickerView.isHidden = true - selectDoneButton.isHidden = true - } -} - -// MARK: - Setup -extension CaptureResultView { - private func setupUI() { - setupResultImageView() - setupTitleTextField() - setupCategoryButton() - setupCategoryPickerView() - } - - private func setupResultImageView() { - addSubview(resultImageView) - resultImageView.atl - .height(equalTo: resultImageView.widthAnchor) - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 100) - .horizontal(equalTo: safeAreaLayoutGuide) - } - - private func setupTitleTextField() { - addSubview(titleTextField) - titleTextField.atl - .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) - .bottom(equalTo: resultImageView.topAnchor, constant: -20) - } - - private func setupCategoryButton() { - addSubview(categoryButton) - categoryButton.atl - .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) - .bottom(equalTo: titleTextField.topAnchor, constant: -5) - } - - private func setupCategoryPickerView() { - addSubview(categoryPickerView) - addSubview(selectDoneButton) - - categoryPickerView.atl - .horizontal(equalTo: safeAreaLayoutGuide) - .bottom(equalTo: bottomAnchor) - - selectDoneButton.atl - .right(equalTo: categoryPickerView.rightAnchor, constant: -10) - .top(equalTo: categoryPickerView.topAnchor, constant: 10) - } -} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift index 2d84be71..d24b3ec2 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -68,15 +68,15 @@ final class AchievementView: UIView { } } - func configureReadOnly(image: UIImage, title: String, category: String) { - resultImageView.image = image - - titleTextField.text = title - titleTextField.isEnabled = false - - categoryButton.setTitle(category, for: .normal) - categoryButton.isEnabled = false - } +// func configureReadOnly(image: UIImage, title: String, category: String) { +// resultImageView.image = image +// +// titleTextField.text = title +// titleTextField.isEnabled = false +// +// categoryButton.setTitle(category, for: .normal) +// categoryButton.isEnabled = false +// } func update(image: UIImage) { resultImageView.image = image @@ -94,11 +94,11 @@ final class AchievementView: UIView { titleTextField.isEnabled = true categoryButton.isEnabled = true } - - func readOnlyMode() { - titleTextField.isEnabled = false - categoryButton.isEnabled = false - } +// +// func readOnlyMode() { +// titleTextField.isEnabled = false +// categoryButton.isEnabled = false +// } func showCategoryPicker() { categoryPickerView.isHidden = false From 21f6ccedef92a1a991ea63bbc831183946db5e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 13:28:09 +0900 Subject: [PATCH 073/188] =?UTF-8?q?[iOS]=20feat:=20CaptureResultVC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CaptureResultCoordinator.swift | 54 -------- .../CaptureResultViewController.swift | 115 ------------------ .../Presentation/Common/AchievementView.swift | 28 ++--- 3 files changed, 14 insertions(+), 183 deletions(-) delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift deleted file mode 100644 index 1c50d72f..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultCoordinator.swift +++ /dev/null @@ -1,54 +0,0 @@ -//// -//// CaptureResultCoordinator.swift -//// -//// -//// Created by 유정주 on 11/20/23. -//// -// -//import UIKit -//import Core -// -//final class CaptureResultCoordinator: Coordinator { -// var parentCoordinator: Coordinator? -// var childCoordinators: [Coordinator] = [] -// var navigationController: UINavigationController -// -// init( -// _ navigationController: UINavigationController, -// _ parentCoordinator: Coordinator? -// ) { -// self.navigationController = navigationController -// self.parentCoordinator = parentCoordinator -// } -// -// func start() { -// -// } -// -// func start(resultImageData: Data) { -// let captureResultVC = CaptureResultViewController(resultImageData: resultImageData) -// captureResultVC.coordinator = self -// -// captureResultVC.navigationItem.leftBarButtonItem = UIBarButtonItem( -// title: "다시 촬영", style: .plain, target: self, -// action: #selector(recaptureButtonAction) -// ) -// -// captureResultVC.navigationItem.rightBarButtonItem = UIBarButtonItem( -// barButtonSystemItem: .done, -// target: self, -// action: #selector(doneButtonAction) -// ) -// -// navigationController.pushViewController(captureResultVC, animated: false) -// } -// -// @objc func recaptureButtonAction() { -// finish(animated: false) -// } -// -// @objc func doneButtonAction() { -// finish(animated: false) -// parentCoordinator?.finish(animated: true) -// } -//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift deleted file mode 100644 index 9fd88637..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/CaptureResult/CaptureResultViewController.swift +++ /dev/null @@ -1,115 +0,0 @@ -//// -//// CaptureResultViewController.swift -//// -//// -//// Created by 유정주 on 11/20/23. -//// -// -//import UIKit -//import Core -//import Design -// -//final class CaptureResultViewController: BaseViewController { -// // MARK: - Properties -// weak var coordinator: CaptureResultCoordinator? -// -// // TODO: ViewModel로 변환 -// private let resultImageData: Data -// private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] -// private var bottomSheet = InputTextViewController() -// -// // MARK: - Init -// init(resultImageData: Data) { -// self.resultImageData = resultImageData -// super.init(nibName: nil, bundle: nil) -// } -// -// required init?(coder: NSCoder) { -// fatalError("init(coder:) has not been implemented") -// } -// -// // MARK: - Life Cycles -// override func viewDidLoad() { -// super.viewDidLoad() -// setupPickerView() -// addTarget() -// -// if let resultImage = convertDataToImage(resultImageData) { -// layoutView.configure(image: resultImage, count: 10) -// } else { -// layoutView.configure(image: MotiImage.sample1, count: 10) -// } -// } -// -// override func viewIsAppearing(_ animated: Bool) { -// super.viewIsAppearing(animated) -// -// showBottomSheet() -// } -// -// override func viewWillDisappear(_ animated: Bool) { -// super.viewWillDisappear(animated) -// bottomSheet.dismiss(animated: true) -// } -// -// private func setupPickerView() { -// layoutView.categoryPickerView.delegate = self -// layoutView.categoryPickerView.dataSource = self -// } -// -// private func addTarget() { -// layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) -// layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) -// } -// -// @objc private func showPicker() { -// hideBottomSheet() -// layoutView.showCategoryPicker() -// } -// -// @objc private func donePicker() { -// layoutView.hideCategoryPicker() -// showBottomSheet() -// } -// -// private func showBottomSheet() { -// bottomSheet.modalPresentationStyle = .pageSheet -// -// if let sheet = bottomSheet.sheetPresentationController { -// sheet.detents = [.small(), .large()] -// sheet.prefersGrabberVisible = true -// sheet.prefersScrollingExpandsWhenScrolledToEdge = false -// sheet.selectedDetentIdentifier = .small -// sheet.largestUndimmedDetentIdentifier = .large -// } -// -// bottomSheet.isModalInPresentation = true -// present(bottomSheet, animated: true) -// } -// -// private func hideBottomSheet() { -// bottomSheet.dismiss(animated: true) -// } -// -// -//} -// -//extension CaptureResultViewController: UIPickerViewDelegate { -// func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { -// layoutView.update(category: categories[row]) -// } -//} -// -//extension CaptureResultViewController: UIPickerViewDataSource { -// func numberOfComponents(in pickerView: UIPickerView) -> Int { -// return 1 -// } -// -// func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { -// return categories.count -// } -// -// func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { -// return categories[row] -// } -//} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift index d24b3ec2..2d84be71 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -68,15 +68,15 @@ final class AchievementView: UIView { } } -// func configureReadOnly(image: UIImage, title: String, category: String) { -// resultImageView.image = image -// -// titleTextField.text = title -// titleTextField.isEnabled = false -// -// categoryButton.setTitle(category, for: .normal) -// categoryButton.isEnabled = false -// } + func configureReadOnly(image: UIImage, title: String, category: String) { + resultImageView.image = image + + titleTextField.text = title + titleTextField.isEnabled = false + + categoryButton.setTitle(category, for: .normal) + categoryButton.isEnabled = false + } func update(image: UIImage) { resultImageView.image = image @@ -94,11 +94,11 @@ final class AchievementView: UIView { titleTextField.isEnabled = true categoryButton.isEnabled = true } -// -// func readOnlyMode() { -// titleTextField.isEnabled = false -// categoryButton.isEnabled = false -// } + + func readOnlyMode() { + titleTextField.isEnabled = false + categoryButton.isEnabled = false + } func showCategoryPicker() { categoryPickerView.isHidden = false From 540a9a2c53c7e0dc1bb5f8c2dd0e53cb5d755a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 14:29:20 +0900 Subject: [PATCH 074/188] =?UTF-8?q?[iOS]=20refactor:=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Capture/CaptureCoordinator.swift | 10 +++++----- .../Sources/Presentation/Capture/CaptureView.swift | 4 ++-- .../Presentation/Capture/CaptureViewController.swift | 10 +++++----- .../Sources/Presentation/Common/AchievementView.swift | 1 + ...tViewController.swift => TextViewBottomSheet.swift} | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) rename iOS/moti/moti/Presentation/Sources/Presentation/Common/{InputTextViewController.swift => TextViewBottomSheet.swift} (95%) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 26bbab78..6e05be3e 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -29,14 +29,14 @@ final class CaptureCoordinator: Coordinator { currentViewController = captureVC - changeCaptureMode() + changeToCaptureMode() let navVC = UINavigationController(rootViewController: captureVC) navVC.modalPresentationStyle = .fullScreen navigationController.present(navVC, animated: true) } - private func changeCaptureMode() { + private func changeToCaptureMode() { guard let currentViewController = currentViewController else { return } currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( title: "취소", style: .plain, target: self, @@ -46,7 +46,7 @@ final class CaptureCoordinator: Coordinator { currentViewController.navigationItem.rightBarButtonItem = nil } - private func changeEditMode() { + private func changeToEditMode() { guard let currentViewController = currentViewController else { return } currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( title: "다시 촬영", style: .plain, target: self, @@ -65,7 +65,7 @@ final class CaptureCoordinator: Coordinator { } @objc func recaptureButtonAction() { - changeCaptureMode() + changeToCaptureMode() currentViewController?.startCapture() } @@ -80,6 +80,6 @@ final class CaptureCoordinator: Coordinator { extension CaptureCoordinator: CaptureViewControllerDelegate { func didCapture() { - changeEditMode() + changeToEditMode() } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 47518a08..f8c4f120 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -66,7 +66,7 @@ final class CaptureView: UIView { previewLayer.session = session } - func captureMode() { + func changeToCaptureMode() { preview.isHidden = false photoButton.isHidden = false cameraSwitchingButton.isHidden = false @@ -75,7 +75,7 @@ final class CaptureView: UIView { achievementView.isHidden = true } - func editMode(image: UIImage) { + func changeToEditMode(image: UIImage) { preview.isHidden = true photoButton.isHidden = true cameraSwitchingButton.isHidden = true diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index d7c63e9f..f730711e 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -21,7 +21,7 @@ final class CaptureViewController: BaseViewController { weak var coordinator: CaptureCoordinator? private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] - private var bottomSheet = InputTextViewController() + private var bottomSheet = TextViewBottomSheet() // Capture Session private var session: AVCaptureSession? @@ -65,12 +65,12 @@ final class CaptureViewController: BaseViewController { layoutView.achievementView.hideCategoryPicker() hideBottomSheet() setupCamera() - layoutView.captureMode() + layoutView.changeToCaptureMode() } func startEdit(image: UIImage) { showBottomSheet() - layoutView.editMode(image: MotiImage.sample1) + layoutView.changeToEditMode(image: MotiImage.sample1) } } @@ -122,7 +122,7 @@ extension CaptureViewController { } layoutView.updatePreviewLayer(session: session) - layoutView.captureMode() + layoutView.changeToCaptureMode() DispatchQueue.global().async { if !session.isRunning { @@ -175,7 +175,7 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { delegate?.didCapture() let rect = CGRect(origin: .zero, size: .init(width: 1000, height: 1000)) let croppedImage = cropImage(image: image, rect: rect) - layoutView.editMode(image: croppedImage) + layoutView.changeToEditMode(image: croppedImage) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift index 2d84be71..f981b488 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -88,6 +88,7 @@ final class AchievementView: UIView { func update(category: String) { categoryButton.setTitle(category, for: .normal) + categoryButton.setTitleColor(.label, for: .normal) } func editMode() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift similarity index 95% rename from iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift rename to iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift index caf9f39f..9baee596 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift @@ -7,7 +7,7 @@ import UIKit -final class InputTextViewController: UIViewController { +final class TextViewBottomSheet: UIViewController { private let textView = { let textView = UITextView() From ab1779c1fb138924872476bcfe9cbc09ec4dc88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 14:32:51 +0900 Subject: [PATCH 075/188] =?UTF-8?q?[iOS]=20feat:=20InputTextViewController?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/InputTextViewController.swift | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift deleted file mode 100644 index caf9f39f..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/InputTextViewController.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// InputTextViewController.swift -// -// -// Created by 유정주 on 11/20/23. -// - -import UIKit - -final class InputTextViewController: UIViewController { - - private let textView = { - let textView = UITextView() - textView.font = .medium - return textView - }() - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = textView.backgroundColor - view.addSubview(textView) - textView.atl - .top(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40) - .bottom(equalTo: view.safeAreaLayoutGuide.bottomAnchor) - .horizontal(equalTo: view.safeAreaLayoutGuide, constant: 20) - } -} - -extension UISheetPresentationController.Detent.Identifier { - static let small = UISheetPresentationController.Detent.Identifier("small") -} - -extension UISheetPresentationController.Detent { - class func small() -> UISheetPresentationController.Detent { - if #available(iOS 16.0, *) { - return UISheetPresentationController.Detent.custom(identifier: .small) { 0.15 * $0.maximumDetentValue } - } else { - return .medium() - } - } -} From 28afbb185200eb97a6b6c9cea50869967202ea2f Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:40:30 +0900 Subject: [PATCH 076/188] =?UTF-8?q?[BE]=20refactor:=20fixture=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fixture에서 한 번에 다수의 테스트 인스턴스를 생성할 수 있도록 수정 --- BE/test/achievement/achievement-fixture.ts | 13 +++++++++++ BE/test/category/category-fixture.ts | 26 ++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/BE/test/achievement/achievement-fixture.ts b/BE/test/achievement/achievement-fixture.ts index 6ba9283c..10325969 100644 --- a/BE/test/achievement/achievement-fixture.ts +++ b/BE/test/achievement/achievement-fixture.ts @@ -15,6 +15,19 @@ export class AchievementFixture { return await this.achievementRepository.saveAchievement(achievement); } + async getAchievements( + count: number, + user: User, + category: Category, + ): Promise { + const achievements: Achievement[] = []; + for (let i = 0; i < count; i++) { + const achievement = await this.getAchievement(user, category); + achievements.push(achievement); + } + return achievements; + } + static achievement(user: User, category: Category) { return new Achievement( user, diff --git a/BE/test/category/category-fixture.ts b/BE/test/category/category-fixture.ts index a3e715d8..eace5d76 100644 --- a/BE/test/category/category-fixture.ts +++ b/BE/test/category/category-fixture.ts @@ -5,14 +5,36 @@ import { CategoryRepository } from '../../src/category/entities/category.reposit @Injectable() export class CategoryFixture { + private static id = 0; + constructor(private readonly categoryRepository: CategoryRepository) {} - async getCategory(user: User, name: string): Promise { - const category = CategoryFixture.category(user, name); + async getCategory(user: User, name?: string): Promise { + const category = CategoryFixture.category( + user, + name || CategoryFixture.getDummyCategoryName(), + ); return await this.categoryRepository.saveCategory(category); } + async getCategories( + count: number, + user: User, + name?: string, + ): Promise { + const categories: Category[] = []; + for (let i = 0; i < count; i++) { + const category = await this.getCategory(user, name); + categories.push(category); + } + return categories; + } + static category(user: User, name: string) { return new Category(user, name); } + + static getDummyCategoryName() { + return `카테고리${++CategoryFixture.id}`; + } } From ffae40c9f2700f5ce5b0143bc5a73cfa304c8f75 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:41:44 +0900 Subject: [PATCH 077/188] =?UTF-8?q?[BE]=20fix:=20Entity=20toModel=20?= =?UTF-8?q?=ED=8C=8C=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 연관관계 엔티티가 undefined일때 toModel을 호출하지 않도록 수정 --- BE/src/achievement/entities/achievement.entity.ts | 4 ++-- BE/src/category/entities/category.entity.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BE/src/achievement/entities/achievement.entity.ts b/BE/src/achievement/entities/achievement.entity.ts index 9e1249f5..10d938a4 100644 --- a/BE/src/achievement/entities/achievement.entity.ts +++ b/BE/src/achievement/entities/achievement.entity.ts @@ -51,8 +51,8 @@ export class AchievementEntity extends BaseTimeEntity { static from(achievement: Achievement) { const achievementEntity = new AchievementEntity(); achievementEntity.id = achievement.id; - achievementEntity.user = UserEntity.from(achievement.user); - achievementEntity.category = CategoryEntity.from(achievement.category); + achievementEntity.user = UserEntity.from(achievement?.user); + achievementEntity.category = CategoryEntity.from(achievement?.category); achievementEntity.title = achievement.title; achievementEntity.content = achievement.content; achievementEntity.imageUrl = achievement.imageUrl; diff --git a/BE/src/category/entities/category.entity.ts b/BE/src/category/entities/category.entity.ts index d89bd1b7..0a1751e3 100644 --- a/BE/src/category/entities/category.entity.ts +++ b/BE/src/category/entities/category.entity.ts @@ -30,7 +30,7 @@ export class CategoryEntity extends BaseTimeEntity { static from(category: Category): CategoryEntity { const categoryEntity = new CategoryEntity(); categoryEntity.id = category.id; - categoryEntity.user = UserEntity.from(category.user); + categoryEntity.user = UserEntity.from(category?.user); categoryEntity.name = category.name; return categoryEntity; } From 71af73e9daaf3584e9215f158f6cca1dee0d1653 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:42:57 +0900 Subject: [PATCH 078/188] =?UTF-8?q?[BE]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 리스트 반환시 필요한 데이터를 저장할 객체 --- BE/src/category/dto/category-metadata.ts | 17 +++++++++++++++++ BE/src/category/index.ts | 6 ++++++ 2 files changed, 23 insertions(+) create mode 100644 BE/src/category/dto/category-metadata.ts create mode 100644 BE/src/category/index.ts diff --git a/BE/src/category/dto/category-metadata.ts b/BE/src/category/dto/category-metadata.ts new file mode 100644 index 00000000..93e97b98 --- /dev/null +++ b/BE/src/category/dto/category-metadata.ts @@ -0,0 +1,17 @@ +import { ICategoryMetaData } from '../index'; + +export class CategoryMetaData { + categoryId: number; + categoryName: string; + insertedAt: Date; + achievementCount: number; + + constructor(categoryMetaData: ICategoryMetaData) { + this.categoryId = categoryMetaData.categoryId; + this.categoryName = categoryMetaData.categoryName; + this.insertedAt = new Date(categoryMetaData.insertedAt); + this.achievementCount = isNaN(Number(categoryMetaData.achievementCount)) + ? 0 + : parseInt(categoryMetaData.achievementCount); + } +} diff --git a/BE/src/category/index.ts b/BE/src/category/index.ts new file mode 100644 index 00000000..f08be050 --- /dev/null +++ b/BE/src/category/index.ts @@ -0,0 +1,6 @@ +export interface ICategoryMetaData { + categoryId: number; + categoryName: string; + insertedAt: string; + achievementCount: string; +} From a529ac5e725d151c57cfb14693f50185fc871494 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:43:37 +0900 Subject: [PATCH 079/188] =?UTF-8?q?[BE]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리에 대한 AchievementEntity 추가 - 인덱스 생성 --- BE/src/category/entities/category.entity.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/BE/src/category/entities/category.entity.ts b/BE/src/category/entities/category.entity.ts index 0a1751e3..0f380518 100644 --- a/BE/src/category/entities/category.entity.ts +++ b/BE/src/category/entities/category.entity.ts @@ -1,15 +1,19 @@ import { Column, Entity, + Index, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import { BaseTimeEntity } from '../../common/entities/base.entity'; import { UserEntity } from '../../users/entities/user.entity'; import { Category } from '../domain/category.domain'; +import { AchievementEntity } from '../../achievement/entities/achievement.entity'; @Entity({ name: 'category' }) +@Index(['user']) export class CategoryEntity extends BaseTimeEntity { @PrimaryGeneratedColumn() id: number; @@ -21,6 +25,9 @@ export class CategoryEntity extends BaseTimeEntity { @Column({ name: 'name' }) name: string; + @OneToMany(() => AchievementEntity, (achievement) => achievement.category) + achievements: AchievementEntity[]; + toModel(): Category { const category = new Category(this.user?.toModel(), this.name); category.id = this.id; From de8b5abc85ce97c9e66f25d02d7fb9bafa340a75 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:44:41 +0900 Subject: [PATCH 080/188] =?UTF-8?q?[BE]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20findByUserWithCount=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findByUserWithCount는 사용자에 대한 카테고리 정보들을 추출하는 메서드 --- .../entities/category.repository.spec.ts | 102 ++++++++++++++---- .../category/entities/category.repository.ts | 19 ++++ 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/BE/src/category/entities/category.repository.spec.ts b/BE/src/category/entities/category.repository.spec.ts index 8b62fdbe..f2e89d39 100644 --- a/BE/src/category/entities/category.repository.spec.ts +++ b/BE/src/category/entities/category.repository.spec.ts @@ -6,15 +6,21 @@ import { typeOrmModuleOptions } from '../../config/typeorm'; import { OperateModule } from '../../operate/operate.module'; import { ConfigModule } from '@nestjs/config'; import { configServiceModuleOptions } from '../../config/config'; -import { CategoryModule } from '../category.module'; import { UsersFixture } from '../../../test/user/users-fixture'; import { Category } from '../domain/category.domain'; import { UsersTestModule } from '../../../test/user/users-test.module'; +import { transactionTest } from '../../../test/common/transaction-test'; +import { AchievementFixture } from '../../../test/achievement/achievement-fixture'; +import { AchievementTestModule } from '../../../test/achievement/achievement-test.module'; +import { CategoryTestModule } from '../../../test/category/category-test.module'; +import { CategoryFixture } from '../../../test/category/category-fixture'; describe('CategoryRepository', () => { let categoryRepository: CategoryRepository; + let categoryFixture: CategoryFixture; let dataSource: DataSource; let usersFixture: UsersFixture; + let achievementFixture: AchievementFixture; beforeAll(async () => { const app: TestingModule = await Test.createTestingModule({ @@ -23,12 +29,15 @@ describe('CategoryRepository', () => { UsersTestModule, OperateModule, ConfigModule.forRoot(configServiceModuleOptions), - CategoryModule, + CategoryTestModule, + AchievementTestModule, ], controllers: [], providers: [], }).compile(); + categoryFixture = app.get(CategoryFixture); + achievementFixture = app.get(AchievementFixture); usersFixture = app.get(UsersFixture); categoryRepository = app.get(CategoryRepository); dataSource = app.get(DataSource); @@ -45,31 +54,78 @@ describe('CategoryRepository', () => { }); it('saveCategory는 카테고리를 생성할 수 있다.', async () => { - // given - const user = await usersFixture.getUser('ABC'); - const category = new Category(user, '카테고리1'); + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = new Category(user, '카테고리1'); - // when - const savedCategory = await categoryRepository.saveCategory(category); + // when + const savedCategory = await categoryRepository.saveCategory(category); - // then - expect(savedCategory.name).toBe('카테고리1'); - expect(savedCategory.user).toEqual(user); + // then + expect(savedCategory.name).toBe('카테고리1'); + expect(savedCategory.user).toEqual(user); + }); }); it('findById는 id로 user를 제외된 카테고리를 조회할 수 있다.', async () => { - // given - const user = await usersFixture.getUser(1); - const category = new Category(user, '카테고리1'); - const savedCategory = await categoryRepository.saveCategory(category); - - // when - const retrievedCategory = await categoryRepository.findById( - savedCategory.id, - ); - - // then - expect(retrievedCategory.name).toBe('카테고리1'); - expect(retrievedCategory.user).toBeUndefined(); + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser(1); + const category = new Category(user, '카테고리1'); + const savedCategory = await categoryRepository.saveCategory(category); + + // when + const retrievedCategory = await categoryRepository.findById( + savedCategory.id, + ); + + // then + expect(retrievedCategory.name).toBe('카테고리1'); + expect(retrievedCategory.user).toBeUndefined(); + }); + }); + + it('findByUserWithCount는 사용자가 소유한 카테고리가 없으면 빈 배열을 반환한다.', async () => { + await transactionTest(dataSource, async () => { + const user = await usersFixture.getUser(1); + + const retrievedCategories = + await categoryRepository.findByUserWithCount(user); + + expect(retrievedCategories).toBeDefined(); + expect(retrievedCategories.length).toBe(0); + }); + }); + + it('findByUserWithCount', async () => { + await transactionTest(dataSource, async () => { + const user = await usersFixture.getUser(1); + const categories: Category[] = await categoryFixture.getCategories( + 4, + user, + ); + await achievementFixture.getAchievements(4, user, categories[0]); + await achievementFixture.getAchievements(5, user, categories[1]); + await achievementFixture.getAchievements(7, user, categories[2]); + await achievementFixture.getAchievements(10, user, categories[3]); + + const retrievedCategories = + await categoryRepository.findByUserWithCount(user); + + expect(retrievedCategories.length).toBe(4); + expect(retrievedCategories[0].categoryId).toEqual(categories[0].id); + expect(retrievedCategories[0].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[0].achievementCount).toBe(4); + expect(retrievedCategories[1].categoryId).toEqual(categories[1].id); + expect(retrievedCategories[1].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[1].achievementCount).toBe(5); + expect(retrievedCategories[2].categoryId).toEqual(categories[2].id); + expect(retrievedCategories[2].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[2].achievementCount).toBe(7); + expect(retrievedCategories[3].categoryId).toEqual(categories[3].id); + expect(retrievedCategories[3].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[3].achievementCount).toBe(10); + }); }); }); diff --git a/BE/src/category/entities/category.repository.ts b/BE/src/category/entities/category.repository.ts index b4d4fa06..e898d8fe 100644 --- a/BE/src/category/entities/category.repository.ts +++ b/BE/src/category/entities/category.repository.ts @@ -2,6 +2,9 @@ import { CustomRepository } from '../../config/typeorm/custom-repository.decorat import { CategoryEntity } from './category.entity'; import { Category } from '../domain/category.domain'; import { TransactionalRepository } from '../../config/transaction-manager/transactional-repository'; +import { User } from '../../users/domain/user.domain'; +import { ICategoryMetaData } from '../index'; +import { CategoryMetaData } from '../dto/category-metadata'; @CustomRepository(CategoryEntity) export class CategoryRepository extends TransactionalRepository { @@ -15,4 +18,20 @@ export class CategoryRepository extends TransactionalRepository const category = await this.repository.findOne({ where: { id: id } }); return category?.toModel(); } + + async findByUserWithCount(user: User): Promise { + const categories = await this.repository + .createQueryBuilder('category') + .select('category.id', 'categoryId') + .addSelect('category.name', 'categoryName') + .addSelect('MAX(achievement.created_at)', 'insertedAt') + .addSelect('COUNT(achievement.id)', 'achievementCount') + .leftJoin('category.achievements', 'achievement') + .where('category.user_id = :user', { user: user.id }) + .orderBy('category.id', 'ASC') + .groupBy('category.id') + .getRawMany(); + + return categories.map((category) => new CategoryMetaData(category)); + } } From 2780f469dc535def1e8f09c4959789806026f795 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:45:18 +0900 Subject: [PATCH 081/188] =?UTF-8?q?[BE]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=20get?= =?UTF-8?q?CategoriesByUsers=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자에 대한 getCategoriesByUsers를 조회하는 비즈니스 로직 --- .../application/category.service.spec.ts | 60 ++++++++++++++++++- .../category/application/category.service.ts | 6 ++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/BE/src/category/application/category.service.spec.ts b/BE/src/category/application/category.service.spec.ts index 91e55b56..7d12a3c0 100644 --- a/BE/src/category/application/category.service.spec.ts +++ b/BE/src/category/application/category.service.spec.ts @@ -7,14 +7,20 @@ import { UsersTestModule } from '../../../test/user/users-test.module'; import { OperateModule } from '../../operate/operate.module'; import { ConfigModule } from '@nestjs/config'; import { configServiceModuleOptions } from '../../config/config'; -import { CategoryModule } from '../category.module'; import { CategoryService } from './category.service'; import { CategoryCreate } from '../dto/category-create'; +import { AchievementTestModule } from '../../../test/achievement/achievement-test.module'; +import { AchievementFixture } from '../../../test/achievement/achievement-fixture'; +import { Category } from '../domain/category.domain'; +import { CategoryTestModule } from '../../../test/category/category-test.module'; +import { CategoryFixture } from '../../../test/category/category-fixture'; describe('CategoryService', () => { let categoryService: CategoryService; let dataSource: DataSource; let usersFixture: UsersFixture; + let categoryFixture: CategoryFixture; + let achievementFixture: AchievementFixture; beforeAll(async () => { const app: TestingModule = await Test.createTestingModule({ @@ -23,12 +29,15 @@ describe('CategoryService', () => { UsersTestModule, OperateModule, ConfigModule.forRoot(configServiceModuleOptions), - CategoryModule, + CategoryTestModule, + AchievementTestModule, ], controllers: [], providers: [], }).compile(); + categoryFixture = app.get(CategoryFixture); + achievementFixture = app.get(AchievementFixture); usersFixture = app.get(UsersFixture); categoryService = app.get(CategoryService); dataSource = app.get(DataSource); @@ -59,4 +68,51 @@ describe('CategoryService', () => { expect(savedCategory.name).toBe('카테고리1'); expect(savedCategory.user).toStrictEqual(user); }); + + describe('getCategoriesByUsers는 카테고리를 조회할 수 있다', () => { + it('user에 대한 카테고리가 없을 때 빈 배열을 반환한다.', async () => { + // given + const user = await usersFixture.getUser(1); + + // when + const retrievedCategories = + await categoryService.getCategoriesByUsers(user); + + // then + expect(retrievedCategories).toBeDefined(); + expect(retrievedCategories.length).toBe(0); + }); + + it('user에 대한 카테고리가 있을 때 카테고리를 반환한다.', async () => { + // given + const user = await usersFixture.getUser(1); + const categories: Category[] = await categoryFixture.getCategories( + 4, + user, + ); + await achievementFixture.getAchievements(4, user, categories[0]); + await achievementFixture.getAchievements(5, user, categories[1]); + await achievementFixture.getAchievements(7, user, categories[2]); + await achievementFixture.getAchievements(10, user, categories[3]); + + // when + const retrievedCategories = + await categoryService.getCategoriesByUsers(user); + + // then + expect(retrievedCategories.length).toBe(4); + expect(retrievedCategories[0].categoryId).toEqual(categories[0].id); + expect(retrievedCategories[0].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[0].achievementCount).toBe(4); + expect(retrievedCategories[1].categoryId).toEqual(categories[1].id); + expect(retrievedCategories[1].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[1].achievementCount).toBe(5); + expect(retrievedCategories[2].categoryId).toEqual(categories[2].id); + expect(retrievedCategories[2].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[2].achievementCount).toBe(7); + expect(retrievedCategories[3].categoryId).toEqual(categories[3].id); + expect(retrievedCategories[3].insertedAt).toBeInstanceOf(Date); + expect(retrievedCategories[3].achievementCount).toBe(10); + }); + }); }); diff --git a/BE/src/category/application/category.service.ts b/BE/src/category/application/category.service.ts index f0e7ac96..467a7a29 100644 --- a/BE/src/category/application/category.service.ts +++ b/BE/src/category/application/category.service.ts @@ -4,6 +4,7 @@ import { CategoryCreate } from '../dto/category-create'; import { Transactional } from '../../config/transaction-manager'; import { Category } from '../domain/category.domain'; import { User } from '../../users/domain/user.domain'; +import { CategoryMetaData } from '../dto/category-metadata'; @Injectable() export class CategoryService { @@ -17,4 +18,9 @@ export class CategoryService { const category = categoryCreate.toModel(user); return await this.categoryRepository.saveCategory(category); } + + @Transactional({ readonly: true }) + async getCategoriesByUsers(user: User): Promise { + return this.categoryRepository.findByUserWithCount(user); + } } From b18e0fc963f7c754e72d5582eaa59ce060071040 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:46:03 +0900 Subject: [PATCH 082/188] =?UTF-8?q?[BE]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EC=9C=84=ED=95=9C=20dto=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 리스트가 비어있어도 응답 가능 - 카테고리 리스트가 있다면 전체 카테고리 리스트에도 반영해줘야 함 --- .../dto/category-list.response.spec.ts | 99 +++++++++++++++++++ BE/src/category/dto/category-list.response.ts | 57 +++++++++++ 2 files changed, 156 insertions(+) create mode 100644 BE/src/category/dto/category-list.response.spec.ts create mode 100644 BE/src/category/dto/category-list.response.ts diff --git a/BE/src/category/dto/category-list.response.spec.ts b/BE/src/category/dto/category-list.response.spec.ts new file mode 100644 index 00000000..39f3acdc --- /dev/null +++ b/BE/src/category/dto/category-list.response.spec.ts @@ -0,0 +1,99 @@ +import { CategoryMetaData } from './category-metadata'; +import { CategoryListResponse } from './category-list.response'; + +describe('CategoryListResponse', () => { + describe('생성된 카테고리가 없더라도 응답이 가능하다.', () => { + it('카테고리 메타데이터가 빈 배열일 때 응답이 가능하다.', () => { + // given + const categoryMetaData: CategoryMetaData[] = []; + + // when + const categoryListResponse = new CategoryListResponse(categoryMetaData); + + // then + expect(categoryListResponse).toBeDefined(); + expect(Object.keys(categoryListResponse.categories)).toHaveLength(1); + expect(categoryListResponse.categoryNames).toHaveLength(1); + expect(categoryListResponse.categoryNames).toContain('전체'); + expect(categoryListResponse.categories).toHaveProperty('전체'); + expect(categoryListResponse.categories['전체'].id).toBe(0); + expect(categoryListResponse.categories['전체'].name).toBe('전체'); + expect(categoryListResponse.categories['전체'].continued).toBe(0); + expect(categoryListResponse.categories['전체'].lastChallenged).toBeNull(); + }); + + it('카테고리 메타데이터가 undefined일 때 응답이 가능하다.', () => { + // given + const categoryMetaData: CategoryMetaData[] = undefined; + + // when + const categoryListResponse = new CategoryListResponse(categoryMetaData); + + // then + expect(categoryListResponse).toBeDefined(); + expect(Object.keys(categoryListResponse.categories)).toHaveLength(1); + expect(categoryListResponse.categoryNames).toHaveLength(1); + expect(categoryListResponse.categoryNames).toContain('전체'); + expect(categoryListResponse.categories).toHaveProperty('전체'); + expect(categoryListResponse.categories['전체'].id).toBe(0); + expect(categoryListResponse.categories['전체'].name).toBe('전체'); + expect(categoryListResponse.categories['전체'].continued).toBe(0); + expect(categoryListResponse.categories['전체'].lastChallenged).toBeNull(); + }); + }); + + describe('생성된 카테고리가 있을 때 응답이 가능하다.', () => { + it('', () => { + // given + const categoryMetaData: CategoryMetaData[] = []; + categoryMetaData.push( + new CategoryMetaData({ + categoryId: 1, + categoryName: '카테고리1', + insertedAt: new Date('2021-01-01T00:00:00.000Z').toISOString(), + achievementCount: '1', + }), + ); + categoryMetaData.push( + new CategoryMetaData({ + categoryId: 2, + categoryName: '카테고리2', + insertedAt: new Date('2021-01-02T00:00:00.000Z').toISOString(), + achievementCount: '2', + }), + ); + + // when + const categoryListResponse = new CategoryListResponse(categoryMetaData); + + // then + expect(categoryListResponse).toBeDefined(); + expect(Object.keys(categoryListResponse.categories)).toHaveLength(3); + expect(categoryListResponse.categoryNames).toHaveLength(3); + expect(categoryListResponse.categories).toHaveProperty('전체'); + expect(categoryListResponse.categoryNames).toContain('전체'); + expect(categoryListResponse.categories).toHaveProperty('카테고리1'); + expect(categoryListResponse.categoryNames).toContain('카테고리1'); + expect(categoryListResponse.categories).toHaveProperty('카테고리2'); + expect(categoryListResponse.categoryNames).toContain('카테고리2'); + expect(categoryListResponse.categories['전체']).toEqual({ + id: 0, + name: '전체', + continued: 3, + lastChallenged: '2021-01-02T00:00:00.000Z', + }); + expect(categoryListResponse.categories['카테고리1']).toEqual({ + id: 1, + name: '카테고리1', + continued: 1, + lastChallenged: '2021-01-01T00:00:00.000Z', + }); + expect(categoryListResponse.categories['카테고리2']).toEqual({ + id: 2, + name: '카테고리2', + continued: 2, + lastChallenged: '2021-01-02T00:00:00.000Z', + }); + }); + }); +}); diff --git a/BE/src/category/dto/category-list.response.ts b/BE/src/category/dto/category-list.response.ts new file mode 100644 index 00000000..42859394 --- /dev/null +++ b/BE/src/category/dto/category-list.response.ts @@ -0,0 +1,57 @@ +import { CategoryMetaData } from './category-metadata'; +import { ApiProperty } from '@nestjs/swagger'; + +interface CategoryList { + [key: string]: CategoryListElementResponse; +} + +export class CategoryListResponse { + @ApiProperty({ description: '카테고리 키 리스트' }) + categoryNames: string[] = []; + @ApiProperty({ description: '카테고리 데이터' }) + categories: CategoryList = {}; + + constructor(categoryMetaData: CategoryMetaData[]) { + categoryMetaData = categoryMetaData || []; + const totalItem = CategoryListElementResponse.totalCategoryElement(); + this.categories[totalItem.name] = totalItem; + this.categoryNames.push(totalItem.name); + categoryMetaData?.forEach((category) => { + this.categoryNames.push(category.categoryName); + + if ( + !totalItem.lastChallenged || + new Date(totalItem.lastChallenged) < category.insertedAt + ) + totalItem.lastChallenged = category.insertedAt.toISOString(); + totalItem.continued += category.achievementCount; + + this.categories[category.categoryName] = new CategoryListElementResponse( + category, + ); + }); + } +} + +export class CategoryListElementResponse { + id: number; + name: string; + continued: number; + lastChallenged: string; + + constructor(category: CategoryMetaData) { + this.id = category.categoryId; + this.name = category.categoryName; + this.continued = category.achievementCount; + this.lastChallenged = category.insertedAt?.toISOString() || null; + } + + static totalCategoryElement() { + return new CategoryListElementResponse({ + categoryId: 0, + categoryName: '전체', + insertedAt: null, + achievementCount: 0, + }); + } +} From 540567b69b2b749fa12dd62cc169d4c141651a9a Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 14:46:55 +0900 Subject: [PATCH 083/188] =?UTF-8?q?[BE]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 조회 API 추가 --- .../category/controller/category.controller.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/BE/src/category/controller/category.controller.ts b/BE/src/category/controller/category.controller.ts index 896991cd..e201ef5d 100644 --- a/BE/src/category/controller/category.controller.ts +++ b/BE/src/category/controller/category.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, Post, @@ -14,6 +15,7 @@ import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decora import { User } from '../../users/domain/user.domain'; import { AccessTokenGuard } from '../../auth/guard/access-token.guard'; import { CategoryResponse } from '../dto/category.response'; +import { CategoryListResponse } from '../dto/category-list.response'; @Controller('/api/v1/category') @ApiTags('카테고리 API') @@ -37,4 +39,18 @@ export class CategoryController { ); return ApiData.success(CategoryResponse.from(category)); } + + @Get() + @UseGuards(AccessTokenGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '카테고리 조회 API', + description: '사용자 본인에 대한 카테고리를 조회합니다.', + }) + async getCategories( + @AuthenticatedUser() user: User, + ): Promise> { + const categories = await this.categoryService.getCategoriesByUsers(user); + return ApiData.success(new CategoryListResponse(categories)); + } } From 470fb6e6b012e0e2d09cb94164123bd79f685e30 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 15:08:51 +0900 Subject: [PATCH 084/188] =?UTF-8?q?[iOS]=20refactor:=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20MockRecord=20->=20?= =?UTF-8?q?MockAchievement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rdListRepository.swift => MockAchievementListRepository.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename iOS/moti/moti/Data/Sources/Repository/Mock/{MockRecordListRepository.swift => MockAchievementListRepository.swift} (100%) diff --git a/iOS/moti/moti/Data/Sources/Repository/Mock/MockRecordListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift similarity index 100% rename from iOS/moti/moti/Data/Sources/Repository/Mock/MockRecordListRepository.swift rename to iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift From ef5accdfcc63a62b4c2ec7c00c7daeab23fc022b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 16:46:22 +0900 Subject: [PATCH 085/188] =?UTF-8?q?[iOS]=20feat:=20=EC=95=A8=EB=B2=94?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AC=EC=A7=84=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Capture/CaptureView.swift | 13 ++- .../Capture/CaptureViewController.swift | 88 +++++++++------ .../Extension/UIImage+Extension.swift | 106 ++++++++++++++++++ 3 files changed, 167 insertions(+), 40 deletions(-) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Extension/UIImage+Extension.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index f8c4f120..1dcb089c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -13,7 +13,7 @@ final class CaptureView: UIView { // MARK: - Views // VC에서 액션을 달아주기 위해 private 제거 - let photoButton = NormalButton(title: "앨범에서 선택", image: SymbolImage.photo) + let albumButton = NormalButton(title: "앨범에서 선택", image: SymbolImage.photo) let cameraSwitchingButton = NormalButton(title: "카메라 전환", image: SymbolImage.iphone) let captureButton = CaptureButton() @@ -68,16 +68,17 @@ final class CaptureView: UIView { func changeToCaptureMode() { preview.isHidden = false - photoButton.isHidden = false + albumButton.isHidden = false cameraSwitchingButton.isHidden = false captureButton.isHidden = false + achievementView.resultImageView.image = nil achievementView.isHidden = true } func changeToEditMode(image: UIImage) { preview.isHidden = true - photoButton.isHidden = true + albumButton.isHidden = true cameraSwitchingButton.isHidden = true captureButton.isHidden = true @@ -106,9 +107,9 @@ private extension CaptureView { } func setupPhotoButton() { - photoButton.setColor(.tabBarItemGray) - addSubview(photoButton) - photoButton.atl + albumButton.setColor(.tabBarItemGray) + addSubview(albumButton) + albumButton.atl .bottom(equalTo: captureButton.bottomAnchor) .right(equalTo: captureButton.leftAnchor, constant: -30) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index f730711e..5b2011b5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -9,6 +9,7 @@ import UIKit import Core import AVFoundation import Design +import PhotosUI protocol CaptureViewControllerDelegate: AnyObject { func didCapture() @@ -59,6 +60,7 @@ final class CaptureViewController: BaseViewController { layoutView.captureButton.addTarget(self, action: #selector(didClickedShutterButton), for: .touchUpInside) layoutView.achievementView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) layoutView.achievementView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) + layoutView.albumButton.addTarget(self, action: #selector(showPHPicker), for: .touchUpInside) } func startCapture() { @@ -70,7 +72,14 @@ final class CaptureViewController: BaseViewController { func startEdit(image: UIImage) { showBottomSheet() - layoutView.changeToEditMode(image: MotiImage.sample1) + layoutView.changeToEditMode(image: image) + } + + private func capturedPicture(image: UIImage) { + guard let croppedImage = image.cropToSquare() else { return } + startEdit(image: croppedImage) + + delegate?.didCapture() } } @@ -141,8 +150,7 @@ extension CaptureViewController { #if targetEnvironment(simulator) // Simulator Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") - delegate?.didCapture() - startEdit(image: MotiImage.sample1) + capturedPicture(image: MotiImage.sample1) #else // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 // - speed: 약간의 노이즈 감소만이 적용 @@ -169,51 +177,63 @@ extension CaptureViewController: AVCapturePhotoCaptureDelegate { session.stopRunning() } - guard let data = photo.fileDataRepresentation() else { return } + guard let data = photo.fileDataRepresentation(), + let image = UIImage(data: data) else { return } - if let image = convertDataToImage(data) { - delegate?.didCapture() - let rect = CGRect(origin: .zero, size: .init(width: 1000, height: 1000)) - let croppedImage = cropImage(image: image, rect: rect) - layoutView.changeToEditMode(image: croppedImage) - } + capturedPicture(image: image) } - - private func convertDataToImage(_ data: Data) -> UIImage? { - guard let image = UIImage(data: data) else { return nil } +} + +// MARK: - Album +extension CaptureViewController: PHPickerViewControllerDelegate { + @objc func showPHPicker() { + var configuration = PHPickerConfiguration() + configuration.selectionLimit = 1 + configuration.filter = .images - #if DEBUG - Logger.debug("이미지 사이즈: \(image.size)") - Logger.debug("이미지 용량: \(data) / \(data.count / 1000) KB\n") - #endif - return image + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + + present(picker, animated: true) } - private func cropImage(image: UIImage, rect: CGRect) -> UIImage { - guard let imageRef = image.cgImage?.cropping(to: rect) else { - return image - } + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + guard let selectItem = results.first else { return } + + let itemProvider = selectItem.itemProvider - let croppedImage = UIImage(cgImage: imageRef) - return croppedImage + guard itemProvider.canLoadObject(ofClass: UIImage.self) else { return } + + itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + guard let self else { return } + + DispatchQueue.main.async { + guard let image = image as? UIImage else { return } + self.capturedPicture(image: image) + } + } } } // MARK: - Bottom Sheet private extension CaptureViewController { func showBottomSheet() { - bottomSheet.modalPresentationStyle = .pageSheet + DispatchQueue.main.async { + self.bottomSheet.modalPresentationStyle = .pageSheet - if let sheet = bottomSheet.sheetPresentationController { - sheet.detents = [.small(), .large()] - sheet.prefersGrabberVisible = true - sheet.prefersScrollingExpandsWhenScrolledToEdge = false - sheet.selectedDetentIdentifier = .small - sheet.largestUndimmedDetentIdentifier = .large - } + if let sheet = self.bottomSheet.sheetPresentationController { + sheet.detents = [.small(), .large()] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.selectedDetentIdentifier = .small + sheet.largestUndimmedDetentIdentifier = .large + } - bottomSheet.isModalInPresentation = true - present(bottomSheet, animated: true) + self.bottomSheet.isModalInPresentation = true + self.present(self.bottomSheet, animated: true) + } } func hideBottomSheet() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Extension/UIImage+Extension.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Extension/UIImage+Extension.swift new file mode 100644 index 00000000..1855b0e4 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Extension/UIImage+Extension.swift @@ -0,0 +1,106 @@ +// +// UIImage+Extension.swift +// +// +// Created by 유정주 on 11/21/23. +// + +import UIKit +import Core + +extension UIImage { + /// 이미지를 정사각형으로 Crop하는 메서드 + func cropToSquare() -> UIImage? { + guard let upImage = self.fixedOrientation(), + let image = upImage.cgImage else { return nil } + #if DEBUG + Logger.debug("원본 이미지 사이즈: (\(image.width), \(image.height))") + #endif + + let cropSize = min(image.width, image.height) + let centerX = (image.width - cropSize) / 2 + let centerY = (image.height - cropSize) / 2 + + let cropRect = CGRect(x: centerX, y: centerY, width: cropSize, height: cropSize) + guard let croppedCGImage = image.cropping(to: cropRect) else { return nil } + + #if DEBUG + Logger.debug("Cropped 이미지 사이즈: (\(croppedCGImage.width), \(croppedCGImage.height))") + #endif + + return UIImage(cgImage: croppedCGImage) + } + + /// 이미지의 방향을 up으로 고정하는 메서드 + // https://gist.github.com/schickling/b5d86cb070130f80bb40?permalink_comment_id=3500925#gistcomment-3500925 + func fixedOrientation() -> UIImage? { + guard imageOrientation != .up else { return self } + + var transform: CGAffineTransform = .identity + switch imageOrientation { + case .down, .downMirrored: + transform = transform.translatedBy(x: size.width, y: size.height) + transform = transform.rotated(by: CGFloat.pi) + case .left, .leftMirrored: + transform = transform.translatedBy(x: size.width, y: 0) + transform = transform.rotated(by: CGFloat.pi / 2.0) + case .right, .rightMirrored: + transform = transform.translatedBy(x: 0, y: size.height) + transform = transform.rotated(by: CGFloat.pi / -2.0) + case .up, .upMirrored: + break + @unknown default: + break + } + + // Flip image one more time if needed to, this is to prevent flipped image + switch imageOrientation { + case .upMirrored, .downMirrored: + transform = transform.translatedBy(x: size.width, y: 0) + transform = transform.scaledBy(x: -1, y: 1) + case .leftMirrored, .rightMirrored: + transform = transform.translatedBy(x: size.height, y: 0) + transform = transform.scaledBy(x: -1, y: 1) + case .up, .down, .left, .right: + break + @unknown default: + break + } + + guard var cgImage = self.cgImage else { return nil } + + autoreleasepool { + guard let colorSpace = cgImage.colorSpace else { return } + + guard let context = CGContext( + data: nil, + width: Int(self.size.width), + height: Int(self.size.height), + bitsPerComponent: cgImage.bitsPerComponent, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + + context.concatenate(transform) + + var drawRect: CGRect = .zero + switch imageOrientation { + case .left, .leftMirrored, .right, .rightMirrored: + drawRect.size = CGSize(width: size.height, height: size.width) + default: + drawRect.size = CGSize(width: size.width, height: size.height) + } + + context.draw(cgImage, in: drawRect) + + guard let newCGImage = context.makeImage() else { + return + } + cgImage = newCGImage + } + + let uiImage = UIImage(cgImage: cgImage, scale: 1, orientation: .up) + return uiImage + } +} From f420ad28ea26ed037acaf7cbfb1ac65ee2870e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 16:53:31 +0900 Subject: [PATCH 086/188] =?UTF-8?q?[iOS]=20refactor:=20DispatchQueue.main?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureViewController.swift | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index 5b2011b5..4ab784ad 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -220,20 +220,18 @@ extension CaptureViewController: PHPickerViewControllerDelegate { // MARK: - Bottom Sheet private extension CaptureViewController { func showBottomSheet() { - DispatchQueue.main.async { - self.bottomSheet.modalPresentationStyle = .pageSheet + bottomSheet.modalPresentationStyle = .pageSheet - if let sheet = self.bottomSheet.sheetPresentationController { - sheet.detents = [.small(), .large()] - sheet.prefersGrabberVisible = true - sheet.prefersScrollingExpandsWhenScrolledToEdge = false - sheet.selectedDetentIdentifier = .small - sheet.largestUndimmedDetentIdentifier = .large - } - - self.bottomSheet.isModalInPresentation = true - self.present(self.bottomSheet, animated: true) + if let sheet = bottomSheet.sheetPresentationController { + sheet.detents = [.small(), .large()] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.selectedDetentIdentifier = .small + sheet.largestUndimmedDetentIdentifier = .large } + + bottomSheet.isModalInPresentation = true + present(bottomSheet, animated: true) } func hideBottomSheet() { From 6ea7495d7fdf53ca3eca01d7b287f58c558e0be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 17:40:12 +0900 Subject: [PATCH 087/188] =?UTF-8?q?[iOS]=20feat:=20=EC=B9=B4=EB=A9=94?= =?UTF-8?q?=EB=9D=BC=20=EC=A0=84=EB=A9=B4,=20=ED=9B=84=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureViewController.swift | 91 ++++++++++++++++--- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index 4ab784ad..744abc23 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -25,7 +25,10 @@ final class CaptureViewController: BaseViewController { private var bottomSheet = TextViewBottomSheet() // Capture Session + private var isBackCamera = true private var session: AVCaptureSession? + private var backCameraInput: AVCaptureDeviceInput? + private var frontCameraInput: AVCaptureDeviceInput? // Photo Output private var output: AVCapturePhotoOutput? @@ -61,6 +64,7 @@ final class CaptureViewController: BaseViewController { layoutView.achievementView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) layoutView.achievementView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) layoutView.albumButton.addTarget(self, action: #selector(showPHPicker), for: .touchUpInside) + layoutView.cameraSwitchingButton.addTarget(self, action: #selector(switchCameraInput), for: .touchUpInside) } func startCapture() { @@ -108,27 +112,33 @@ extension CaptureViewController { } private func setupCamera() { - guard let device = AVCaptureDevice.default(for: .video) else { return } - // 세션을 만들고 input, output 연결 let session = AVCaptureSession() - session.sessionPreset = .photo - do { - let input = try AVCaptureDeviceInput(device: device) - if session.canAddInput(input) { - session.addInput(input) - Logger.debug("Add AVCaptureDeviceInput") - } - } catch { - Logger.debug(error) + session.beginConfiguration() + + if session.canSetSessionPreset(.photo) { + session.sessionPreset = .photo } - + + setupBackCamera(session: session) + setupFrontCamera(session: session) + + if isBackCamera, + let backCameraInput = backCameraInput { + session.addInput(backCameraInput) + } else if let frontCameraInput = frontCameraInput { + session.addInput(frontCameraInput) + } + output = AVCapturePhotoOutput() if let output = output, session.canAddOutput(output) { session.addOutput(output) Logger.debug("Add AVCapturePhotoOutput") } + + session.commitConfiguration() + self.session = session layoutView.updatePreviewLayer(session: session) layoutView.changeToCaptureMode() @@ -141,7 +151,32 @@ extension CaptureViewController { Logger.debug("Session Already Running") } } - self.session = session + } + + // 후면 카메라 설정 + private func setupBackCamera(session: AVCaptureSession) { + if let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let backCameraInput = try? AVCaptureDeviceInput(device: backCamera) { + if session.canAddInput(backCameraInput) { + Logger.debug("Add Back Camera Input") + self.backCameraInput = backCameraInput + } + } else { + Logger.error("후면 카메라를 추가할 수 없음") + } + } + + // 전면 카메라 설정 + private func setupFrontCamera(session: AVCaptureSession) { + if let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front), + let frontCameraInput = try? AVCaptureDeviceInput(device: frontCamera) { + if session.canAddInput(frontCameraInput) { + Logger.debug("Add Front Camera Input") + self.frontCameraInput = frontCameraInput + } + } else { + Logger.error("전면 카메라를 추가할 수 없음") + } } @objc private func didClickedShutterButton() { @@ -164,9 +199,39 @@ extension CaptureViewController { // Actual Device let setting = AVCapturePhotoSettings() setting.photoQualityPrioritization = .balanced + + // 전면 카메라일 때 좌우반전 output 설정 + if let connection = output?.connection(with: .video) { + print("isBackCamera: \(isBackCamera)") + connection.isVideoMirrored = !isBackCamera + } output?.capturePhoto(with: setting, delegate: self) #endif } + + @objc func switchCameraInput(_ sender: NormalButton) { + guard let session = session else { return } + guard let backCameraInput = backCameraInput, + let frontCameraInput = frontCameraInput else { return } + + sender.isUserInteractionEnabled = false + session.beginConfiguration() + + if isBackCamera { + // 전면 카메라로 전환 + session.removeInput(backCameraInput) + session.addInput(frontCameraInput) + isBackCamera = false + } else { + // 후면 카메라로 전환 + session.removeInput(frontCameraInput) + session.addInput(backCameraInput) + isBackCamera = true + } + + session.commitConfiguration() + sender.isUserInteractionEnabled = true + } } extension CaptureViewController: AVCapturePhotoCaptureDelegate { From 36c14b9bd1a79d9a482549779e5e97e924290871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 17:47:39 +0900 Subject: [PATCH 088/188] =?UTF-8?q?[iOS]=20feat:=20=EC=A0=84=EB=A9=B4,=20?= =?UTF-8?q?=ED=9B=84=EB=A9=B4=20=EC=B9=B4=EB=A9=94=EB=9D=BC=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Capture/CaptureView.swift | 14 +++++++++++++- .../Capture/CaptureViewController.swift | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 1dcb089c..5817f039 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -14,7 +14,11 @@ final class CaptureView: UIView { // MARK: - Views // VC에서 액션을 달아주기 위해 private 제거 let albumButton = NormalButton(title: "앨범에서 선택", image: SymbolImage.photo) - let cameraSwitchingButton = NormalButton(title: "카메라 전환", image: SymbolImage.iphone) + let cameraSwitchingButton = { + let button = NormalButton() + button.setTitle("카메라 전환", for: .normal) + return button + }() let captureButton = CaptureButton() // Video Preview @@ -85,6 +89,14 @@ final class CaptureView: UIView { achievementView.isHidden = false achievementView.configureEdit(image: image) } + + func changeToBackCamera() { + cameraSwitchingButton.setImage(SymbolImage.iphone, for: .normal) + } + + func changeToFrontCamera() { + cameraSwitchingButton.setImage(SymbolImage.iphoneCamera, for: .normal) + } } // MARK: - Setup diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index 744abc23..7b5a0ac9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -142,6 +142,11 @@ extension CaptureViewController { layoutView.updatePreviewLayer(session: session) layoutView.changeToCaptureMode() + if isBackCamera { + layoutView.changeToBackCamera() + } else { + layoutView.changeToFrontCamera() + } DispatchQueue.global().async { if !session.isRunning { @@ -202,7 +207,6 @@ extension CaptureViewController { // 전면 카메라일 때 좌우반전 output 설정 if let connection = output?.connection(with: .video) { - print("isBackCamera: \(isBackCamera)") connection.isVideoMirrored = !isBackCamera } output?.capturePhoto(with: setting, delegate: self) @@ -222,11 +226,13 @@ extension CaptureViewController { session.removeInput(backCameraInput) session.addInput(frontCameraInput) isBackCamera = false + layoutView.changeToFrontCamera() } else { // 후면 카메라로 전환 session.removeInput(frontCameraInput) session.addInput(backCameraInput) isBackCamera = true + layoutView.changeToBackCamera() } session.commitConfiguration() From b06dfff7265a37be5ef28f8e30a46673b2aedb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Tue, 21 Nov 2023 17:53:16 +0900 Subject: [PATCH 089/188] =?UTF-8?q?[iOS]=20feat:=20session=20stop=EB=8F=84?= =?UTF-8?q?=20background=20thread=EC=97=90=EC=84=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Capture/CaptureViewController.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index 7b5a0ac9..aa3eb9f8 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -47,10 +47,12 @@ final class CaptureViewController: BaseViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if let session = session, - session.isRunning { - Logger.debug("Session Stop Running") - session.stopRunning() + DispatchQueue.global().async { + guard let session = self.session else { return } + if session.isRunning { + Logger.debug("Session Stop Running") + session.stopRunning() + } } } From fb96c5064e2e128e31858db2a66f76bc92420d55 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 18:09:38 +0900 Subject: [PATCH 090/188] =?UTF-8?q?[iOS]=20feat:=20=EC=A0=95=ED=94=BC?= =?UTF-8?q?=EC=85=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- iOS/moti/moti.xcodeproj/project.pbxproj | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/iOS/moti/moti.xcodeproj/project.pbxproj b/iOS/moti/moti.xcodeproj/project.pbxproj index c3b9dc4d..d0ec0e03 100644 --- a/iOS/moti/moti.xcodeproj/project.pbxproj +++ b/iOS/moti/moti.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 209538A22B0C9DDB00B4BF4D /* Jeongfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 209538A12B0C9DDB00B4BF4D /* Jeongfisher */; }; 20CC16862AFB72FE001E7ECE /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = 20CC16852AFB72FE001E7ECE /* .swiftlint.yml */; }; 9B3715DA2AFE2BA70041FCE7 /* Presentation in Frameworks */ = {isa = PBXBuildFile; productRef = 9B3715D92AFE2BA70041FCE7 /* Presentation */; }; 9BA349EA2B0202A90048928D /* Core in Frameworks */ = {isa = PBXBuildFile; productRef = 9BA349E92B0202A90048928D /* Core */; }; @@ -46,6 +47,7 @@ 9BA349EA2B0202A90048928D /* Core in Frameworks */, 9B3715DA2AFE2BA70041FCE7 /* Presentation in Frameworks */, 9BE384E32AFCA7B100207BC4 /* Data in Frameworks */, + 209538A22B0C9DDB00B4BF4D /* Jeongfisher in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -135,6 +137,7 @@ 9BE384E22AFCA7B100207BC4 /* Data */, 9B3715D92AFE2BA70041FCE7 /* Presentation */, 9BA349E92B0202A90048928D /* Core */, + 209538A12B0C9DDB00B4BF4D /* Jeongfisher */, ); productName = moti; productReference = 9BDD6D4A2AFB303200B65102 /* moti.app */; @@ -165,6 +168,7 @@ ); mainGroup = 9BDD6D412AFB303200B65102; packageReferences = ( + 209538A02B0C9DDB00B4BF4D /* XCRemoteSwiftPackageReference "Jeongfisher" */, ); productRefGroup = 9BDD6D4B2AFB303200B65102 /* Products */; projectDirPath = ""; @@ -445,7 +449,23 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 209538A02B0C9DDB00B4BF4D /* XCRemoteSwiftPackageReference "Jeongfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jeongju9216/Jeongfisher.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 209538A12B0C9DDB00B4BF4D /* Jeongfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 209538A02B0C9DDB00B4BF4D /* XCRemoteSwiftPackageReference "Jeongfisher" */; + productName = Jeongfisher; + }; 9B3715D92AFE2BA70041FCE7 /* Presentation */ = { isa = XCSwiftPackageProductDependency; productName = Presentation; From 135e860c73bd120b028d3b3d4b01042cc27c9aeb Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 18:16:16 +0900 Subject: [PATCH 091/188] =?UTF-8?q?[iOS]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20repository=20Mock=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=ED=9B=84=20=EC=8B=A4=EC=A0=9C=20repository=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/xcschemes/DataTests.xcscheme | 53 +++++++++++++++++++ .../Presentation/Home/HomeCoordinator.swift | 2 +- .../TabBar/TabBarCoordinator.swift | 2 +- 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 iOS/moti/moti/Data/.swiftpm/xcode/xcshareddata/xcschemes/DataTests.xcscheme diff --git a/iOS/moti/moti/Data/.swiftpm/xcode/xcshareddata/xcschemes/DataTests.xcscheme b/iOS/moti/moti/Data/.swiftpm/xcode/xcshareddata/xcschemes/DataTests.xcscheme new file mode 100644 index 00000000..a33a8b1b --- /dev/null +++ b/iOS/moti/moti/Data/.swiftpm/xcode/xcshareddata/xcschemes/DataTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift index ba49c0da..3a1a4afb 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift @@ -23,7 +23,7 @@ public final class HomeCoordinator: Coordinator { } public func start() { - let homeVM = HomeViewModel(fetchAchievementListUseCase: .init(repository: MockAchievementListRepository())) + let homeVM = HomeViewModel(fetchAchievementListUseCase: .init(repository: AchievementListRepository())) let homeVC = HomeViewController(viewModel: homeVM) homeVC.coordinator = self navigationController.viewControllers = [homeVC] diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift index 6a96157a..7f1e511b 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift @@ -62,7 +62,7 @@ public final class TabBarCoordinator: Coordinator { // MARK: - Make Child ViewControllers private extension TabBarCoordinator { func makeIndividualTabPage() -> UINavigationController { - let homeVM = HomeViewModel(fetchAchievementListUseCase: .init(repository: MockAchievementListRepository())) + let homeVM = HomeViewModel(fetchAchievementListUseCase: .init(repository: AchievementListRepository())) let homeVC = HomeViewController(viewModel: homeVM) homeVC.tabBarItem.image = SymbolImage.individualTabItem From abd81483b0324cdb976b2be698ac0bbf2b24f521 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 18:17:11 +0900 Subject: [PATCH 092/188] =?UTF-8?q?[iOS]=20feat:=20=EC=A0=95=ED=94=BC?= =?UTF-8?q?=EC=85=94=20=EC=82=AC=EC=9A=A9=20&=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20id=20=EB=B6=84=EA=B8=B0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Cell/AchievementCollectionViewCell.swift | 10 +++++++--- .../Sources/Presentation/Home/HomeViewController.swift | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/AchievementCollectionViewCell.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/AchievementCollectionViewCell.swift index 98257276..fd2cedc6 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/AchievementCollectionViewCell.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/AchievementCollectionViewCell.swift @@ -7,6 +7,7 @@ import UIKit import Design +import Jeongfisher final class AchievementCollectionViewCell: UICollectionViewCell { private let imageView = { @@ -40,8 +41,11 @@ final class AchievementCollectionViewCell: UICollectionViewCell { imageView.image = nil } - func configure(imageURL: String) { - imageView.image = MotiImage.sample1 + func configure(imageURL: URL?) { +// imageView.image = MotiImage.sample1 + if let imageURL { + imageView.jf.setImage(with: imageURL) + } } func showSkeleton() { @@ -53,6 +57,6 @@ final class AchievementCollectionViewCell: UICollectionViewCell { } func cancelDownloadImage() { -// imageView.jf.cancelDownloadImage() + imageView.jf.cancelDownloadImage() } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 45e17e1e..85241661 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -77,7 +77,7 @@ final class HomeViewController: BaseViewController { cellProvider: { collectionView, indexPath, item in let cell: AchievementCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) - if item.id.isEmpty { + if item.id == -1 { cell.showSkeleton() } else { cell.hideSkeleton() From ae25f329faca38ed27f7575a9fa310039a882afa Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 18:19:02 +0900 Subject: [PATCH 093/188] =?UTF-8?q?[iOS]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20repository=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AchievementListRepositoryTests.swift | 38 +++++++++++++++++++ .../NetworkLoginRepositoryTests.swift | 1 + 2 files changed, 39 insertions(+) create mode 100644 iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift new file mode 100644 index 00000000..982388f0 --- /dev/null +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift @@ -0,0 +1,38 @@ +// +// AchievementListRepositoryTests.swift +// +// +// Created by Kihyun Lee on 11/21/23. +// + +import XCTest +@testable import Data +@testable import Domain + +final class AchievementListRepositoryTests: 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. + } + + // TODO: xcconfig baseURL nil error + func test_queryParam_없이_AchievementList_요청시_nil을_return하는가() throws { + let repository = AchievementListRepository() + let expectation = XCTestExpectation(description: "test_queryParam_없이_AchievementList_요청시_nil을_return하는가") + + Task { + let result = try await repository.fetchAchievementList() + + XCTAssertNotNil(result) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3) + } + +} diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift index 631ca404..86f7c94b 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift @@ -23,6 +23,7 @@ final class NetworkLoginRepositoryTests: XCTestCase { // Put teardown code here. This method is called after the invocation of each test method in the class. } + // TODO: xcconfig baseURL nil error func test_login을_요청하면_결과값이_존재함() throws { let repository = LoginRepository() let expectation = XCTestExpectation(description: "test_login을_요청하면_결과값이_존재함") From d06e7189123fad0577f496f36896015b16657129 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 18:20:01 +0900 Subject: [PATCH 094/188] =?UTF-8?q?[iOS]=20feat:=20MotiAPI=EC=97=90=20fetc?= =?UTF-8?q?hAchievementList=20case=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift index 242310e2..7f386bc0 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift @@ -12,6 +12,7 @@ enum MotiAPI: EndpointProtocol { case version case login(requestValue: LoginRequestValue) case autoLogin(requestValue: AutoLoginRequestValue) + case fetchAchievementList(requestValue: FetchAchievementListRequestValue?) } extension MotiAPI { @@ -28,6 +29,7 @@ extension MotiAPI { case .version: return "/operate/policy" case .login: return "/auth/login" case .autoLogin: return "/auth/refresh" + case .fetchAchievementList: return "/achievements" } } @@ -36,6 +38,7 @@ extension MotiAPI { case .version: return .get case .login: return .post case .autoLogin: return .post + case .fetchAchievementList: return .get } } @@ -51,6 +54,8 @@ extension MotiAPI { return requestValue case .autoLogin(let requestValue): return requestValue + case .fetchAchievementList(let requestValue): + return requestValue } } @@ -62,7 +67,7 @@ extension MotiAPI { break case .login: break - case .autoLogin: + case .autoLogin, .fetchAchievementList: // TODO: Keychain Storage로 변경 if let accessToken = UserDefaults.standard.string(forKey: "accessToken") { header["Authorization"] = "Bearer \(accessToken)" From b13191c600274d476720119522bef6d67b53b1cb Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 18:21:51 +0900 Subject: [PATCH 095/188] =?UTF-8?q?[iOS]=20feat:=20AchievementListDTO=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20&=20Achievement=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SimpleDTO 추가 - 타입 변경 및 옵셔널 변경 --- .../Data/Network/DTO/AchievementDTO.swift | 39 ----------------- .../Data/Network/DTO/AchievementListDTO.swift | 43 +++++++++++++++++++ .../Sources/Domain/Entity/Achievement.swift | 10 ++--- 3 files changed, 48 insertions(+), 44 deletions(-) delete mode 100644 iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementDTO.swift create mode 100644 iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementDTO.swift deleted file mode 100644 index 377bd2c9..00000000 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementDTO.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// File.swift -// -// -// Created by Kihyun Lee on 11/14/23. -// - -import Foundation -import Domain - -struct AchievementListResponseDTO: ResponseDataDTO { - var success: Bool? - var message: String? - var data: [AchievementDTO]? -} - -struct AchievementDTO: Codable { - let id: String? - let category: String? - let title: String? - let imageURL: String? - let body: String? - let achieveCount: String? - let date: String? -} - -extension Achievement { - init(dto: AchievementDTO) { - self.init( - id: dto.id ?? "", - category: dto.category ?? "", - title: dto.title ?? "", - imageURL: dto.imageURL ?? "", - body: dto.body ?? "", - achieveCount: dto.achieveCount ?? "", - date: dto.date ?? "" - ) - } -} diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift new file mode 100644 index 00000000..814f90ab --- /dev/null +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift @@ -0,0 +1,43 @@ +// +// AchievementListDTO.swift +// +// +// Created by Kihyun Lee on 11/14/23. +// + +import Foundation +import Domain + +struct AchievementListResponseDTO: ResponseDataDTO { + let success: Bool? + let message: String? + let data: AchievementListResponseDataDTO? +} + +struct AchievementListResponseDataDTO: Codable { + let data: [AchievementSimpleDTO]? + let count: Int? + let next: AchievementListResponseNextDTO? +} + +struct AchievementListResponseNextDTO: Codable { + let take: Int? + let whereIdLessThan: Int? + let categoryId: Int? +} + +struct AchievementSimpleDTO: Codable { + let id: Int? + let thumbnailUrl: String? + let title: String? +} + +extension Achievement { + init(dto: AchievementSimpleDTO) { + self.init( + id: dto.id ?? -1, + title: dto.title ?? "", + imageURL: URL(string: dto.thumbnailUrl ?? "") + ) + } +} diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index edbafdad..9f9fc4d7 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -8,15 +8,15 @@ import Foundation public struct Achievement: Hashable { - public let id: String + public let id: Int public let category: String public let title: String - public let imageURL: String + public let imageURL: URL? public let body: String - public let achieveCount: String - public let date: String + public let achieveCount: Int + public let date: Date? - public init(id: String, category: String, title: String, imageURL: String, body: String, achieveCount: String, date: String) { + public init(id: Int, category: String = "", title: String = "", imageURL: URL? = nil, body: String = "", achieveCount: Int = 0, date: Date? = nil) { self.id = id self.category = category self.title = title From 1451ad4a2ba2775dd3f7d8a50316b906cffd214f Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 18:22:26 +0900 Subject: [PATCH 096/188] =?UTF-8?q?[iOS]=20feat:=20fetchAchievementList=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AchievementListRepository.swift | 27 +++++++++++++++++++ .../Mock/MockAchievementListRepository.swift | 13 ++++----- .../AchievementListRepositoryProtocol.swift | 2 +- .../UseCase/FetchAchievementListUseCase.swift | 10 +++++-- 4 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift diff --git a/iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift new file mode 100644 index 00000000..7f223f1d --- /dev/null +++ b/iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift @@ -0,0 +1,27 @@ +// +// AchievementListRepository.swift +// +// +// Created by Kihyun Lee on 11/21/23. +// + +import Foundation +import Domain +import Core + +public struct AchievementListRepository: AchievementListRepositoryProtocol { + private let provider: ProviderProtocol + + public init(provider: ProviderProtocol = Provider()) { + self.provider = provider + } + + public func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) async throws -> [Achievement] { + let endpoint = MotiAPI.fetchAchievementList(requestValue: requestValue) + let responseDTO = try await provider.request(with: endpoint, type: AchievementListResponseDTO.self) + + guard let achievementListDataDTO = responseDTO.data else { throw NetworkError.decode } + guard let achievementListDTO = achievementListDataDTO.data else { throw NetworkError.decode } + return achievementListDTO.map { Achievement(dto: $0) } + } +} diff --git a/iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift index 6f6d27a0..b713b66f 100644 --- a/iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift @@ -10,7 +10,7 @@ import Domain public struct MockAchievementListRepository: AchievementListRepositoryProtocol { public init() { } - public func fetchAchievementList() async throws -> [Achievement] { + public func fetchAchievementList(requestValue: FetchAchievementListRequestValue?) async throws -> [Achievement] { let json = """ { "success": true, @@ -56,10 +56,11 @@ public struct MockAchievementListRepository: AchievementListRepositoryProtocol { } """ - guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } - let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) - - guard let achievementDTO = achievementListResponseDTO.data else { throw NetworkError.decode } - return achievementDTO.map { Achievement(dto: $0) } +// guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } +// let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) +// +// guard let achievementDTO = achievementListResponseDTO.data else { throw NetworkError.decode } +// return achievementDTO.map { Achievement(dto: $0) } + return [] } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift index e7c387b9..4dc66775 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift @@ -8,5 +8,5 @@ import Foundation public protocol AchievementListRepositoryProtocol { - func fetchAchievementList() async throws -> [Achievement] + func fetchAchievementList(requestValue: FetchAchievementListRequestValue?) async throws -> [Achievement] } diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift index d190182f..26d80c4a 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift @@ -7,6 +7,12 @@ import Foundation +public struct FetchAchievementListRequestValue: RequestValue { + public let categoryId: Int + public let take: Int + public let whereIdLessThan: Int +} + public struct FetchAchievementListUseCase { private let repository: AchievementListRepositoryProtocol @@ -14,7 +20,7 @@ public struct FetchAchievementListUseCase { self.repository = repository } - public func execute() async throws -> [Achievement] { - return try await repository.fetchAchievementList() + public func execute(requestValue: FetchAchievementListRequestValue? = nil) async throws -> [Achievement] { + return try await repository.fetchAchievementList(requestValue: requestValue) } } From 406fe97bdde8c0e0327913784131744c7e7a4536 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 18:34:54 +0900 Subject: [PATCH 097/188] =?UTF-8?q?[BE]=20refactor:=20getCategoriesByUser?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getCategoriesByUsers 에서 수정 --- BE/src/category/application/category.service.spec.ts | 4 ++-- BE/src/category/application/category.service.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BE/src/category/application/category.service.spec.ts b/BE/src/category/application/category.service.spec.ts index 7d12a3c0..103a317e 100644 --- a/BE/src/category/application/category.service.spec.ts +++ b/BE/src/category/application/category.service.spec.ts @@ -76,7 +76,7 @@ describe('CategoryService', () => { // when const retrievedCategories = - await categoryService.getCategoriesByUsers(user); + await categoryService.getCategoriesByUser(user); // then expect(retrievedCategories).toBeDefined(); @@ -97,7 +97,7 @@ describe('CategoryService', () => { // when const retrievedCategories = - await categoryService.getCategoriesByUsers(user); + await categoryService.getCategoriesByUser(user); // then expect(retrievedCategories.length).toBe(4); diff --git a/BE/src/category/application/category.service.ts b/BE/src/category/application/category.service.ts index 467a7a29..e37afa0a 100644 --- a/BE/src/category/application/category.service.ts +++ b/BE/src/category/application/category.service.ts @@ -20,7 +20,7 @@ export class CategoryService { } @Transactional({ readonly: true }) - async getCategoriesByUsers(user: User): Promise { + async getCategoriesByUser(user: User): Promise { return this.categoryRepository.findByUserWithCount(user); } } From 650186e3b55f300a0db5b1314b4bcd48c3a93a36 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Tue, 21 Nov 2023 18:35:25 +0900 Subject: [PATCH 098/188] =?UTF-8?q?[BE]=20refactor:=20api=20end=20point=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - category => categories --- BE/src/category/controller/category.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BE/src/category/controller/category.controller.ts b/BE/src/category/controller/category.controller.ts index e201ef5d..20f30036 100644 --- a/BE/src/category/controller/category.controller.ts +++ b/BE/src/category/controller/category.controller.ts @@ -17,7 +17,7 @@ import { AccessTokenGuard } from '../../auth/guard/access-token.guard'; import { CategoryResponse } from '../dto/category.response'; import { CategoryListResponse } from '../dto/category-list.response'; -@Controller('/api/v1/category') +@Controller('/api/v1/categories') @ApiTags('카테고리 API') export class CategoryController { constructor(private readonly categoryService: CategoryService) {} @@ -50,7 +50,7 @@ export class CategoryController { async getCategories( @AuthenticatedUser() user: User, ): Promise> { - const categories = await this.categoryService.getCategoriesByUsers(user); + const categories = await this.categoryService.getCategoriesByUser(user); return ApiData.success(new CategoryListResponse(categories)); } } From 50d99fe341c012af74c6649f780f1499069b8cb5 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 19:19:03 +0900 Subject: [PATCH 099/188] =?UTF-8?q?[iOS]=20refactor:=20MockRepository=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Data 레이어 -> 테스트 폴더 --- .../Repository/Mock/MockAchievementListRepository.swift | 0 .../DataTests}/Repository/Mock/MockLoginRepository.swift | 0 .../DataTests}/Repository/Mock/MockVersionRepository.swift | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename iOS/moti/moti/Data/{Sources => Tests/DataTests}/Repository/Mock/MockAchievementListRepository.swift (100%) rename iOS/moti/moti/Data/{Sources => Tests/DataTests}/Repository/Mock/MockLoginRepository.swift (100%) rename iOS/moti/moti/Data/{Sources => Tests/DataTests}/Repository/Mock/MockVersionRepository.swift (100%) diff --git a/iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift similarity index 100% rename from iOS/moti/moti/Data/Sources/Repository/Mock/MockAchievementListRepository.swift rename to iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift diff --git a/iOS/moti/moti/Data/Sources/Repository/Mock/MockLoginRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift similarity index 100% rename from iOS/moti/moti/Data/Sources/Repository/Mock/MockLoginRepository.swift rename to iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift diff --git a/iOS/moti/moti/Data/Sources/Repository/Mock/MockVersionRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockVersionRepository.swift similarity index 100% rename from iOS/moti/moti/Data/Sources/Repository/Mock/MockVersionRepository.swift rename to iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockVersionRepository.swift From 93f36b08429327ab2bead6c08ecc88cc9e6b4a89 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 19:19:56 +0900 Subject: [PATCH 100/188] =?UTF-8?q?[iOS]=20refactor:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20Mock=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AchievementListRepositoryTests.swift | 2 +- .../Mock/MockAchievementListRepository.swift | 121 +++++++++++------- 2 files changed, 77 insertions(+), 46 deletions(-) diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift index 982388f0..43c47a5a 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift @@ -21,7 +21,7 @@ final class AchievementListRepositoryTests: XCTestCase { // TODO: xcconfig baseURL nil error func test_queryParam_없이_AchievementList_요청시_nil을_return하는가() throws { - let repository = AchievementListRepository() + let repository = MockAchievementListRepository() let expectation = XCTestExpectation(description: "test_queryParam_없이_AchievementList_요청시_nil을_return하는가") Task { diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift index b713b66f..4cbc48c4 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift @@ -7,60 +7,91 @@ import Foundation import Domain +import Data public struct MockAchievementListRepository: AchievementListRepositoryProtocol { public init() { } - public func fetchAchievementList(requestValue: FetchAchievementListRequestValue?) async throws -> [Achievement] { + public func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) async throws -> [Achievement] { let json = """ { "success": true, - "message": "성공 메시지 예시", - "data": [ - { - "id": "A4Yd01C", - "category": "다이어트", - "title": "잡채 다이어트 1일차", - "imageURL": "https://public.codesquad.kr/jk/storeapp/data/main/310_ZIP_P_0012_T.jpg", - "body": "다이어트는 너무 싫다. 그래도 잡채는 맛있다.", - "achieveCount": "1", - "date": "2023-10-29" - }, - { - "id": "BBBBBBB", - "category": "다이어트", - "title": "잡채 다이어트 실패..", - "imageURL": "https://public.codesquad.kr/jk/storeapp/data/main/310_ZIP_P_0012_T.jpg", - "body": "잡채를 한바가지 먹어버렸다 ....", - "achieveCount": "0", - "date": "2023-11-01" - }, - { - "id": "CCCCCCC", - "category": "다이어트", - "title": "잡채 다이어트 다시 시작", - "imageURL": "https://public.codesquad.kr/jk/storeapp/data/main/310_ZIP_P_0012_T.jpg", - "body": "다시 다이어트는 너무 싫다. 그래도 잡채는 맛있다.", - "achieveCount": "1", - "date": "2023-11-04" - }, - { - "id": "DDDDDDD", - "category": "다이어트", - "title": "닭가슴살 다이어트 시작", - "imageURL": "https://oasisproduct.cdn.ntruss.com/76863/detail/detail_76863_0_b1616cc8-3a25-41a7-a145-613b50eb75b4.jpg", - "body": "다이어트는 너무 싫다. 그리고 닭가슴살은 맛없다.", - "achieveCount": "4", - "date": "2023-08-31" + "data": { + "data": [ + { + "id": 300, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "tend" + }, + { + "id": 299, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "yard" + }, + { + "id": 298, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "improve" + }, + { + "id": 297, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "tonight" + }, + { + "id": 296, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "drug" + }, + { + "id": 295, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "plan" + }, + { + "id": 294, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "number" + }, + { + "id": 293, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "help" + }, + { + "id": 292, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "box" + }, + { + "id": 291, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "above" + }, + { + "id": 290, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "woman" + }, + { + "id": 289, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "accept" + } + ], + "count": 12, + "next": { + "take": 12, + "whereIdLessThan": 289 } - ] + } } """ -// guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } -// let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) -// -// guard let achievementDTO = achievementListResponseDTO.data else { throw NetworkError.decode } -// return achievementDTO.map { Achievement(dto: $0) } - return [] + guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } + let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) + + guard let achievementListResponseDataDTO = achievementListResponseDTO.data else { throw NetworkError.decode } + guard let achievementSimpleDTOs = achievementListResponseDataDTO.data else { throw NetworkError.decode } + return achievementSimpleDTOs.map { Achievement(dto: $0) } } } From d7fe41676f86550228926293b661ce98310a8e8c Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 19:31:24 +0900 Subject: [PATCH 101/188] =?UTF-8?q?[iOS]=20refactor:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20body=20->=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Data/Network/Endpoint/MotiAPI.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift index 7f386bc0..bb1b2173 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift @@ -43,7 +43,16 @@ extension MotiAPI { } var queryParameters: Encodable? { - return nil + switch self { + case .version: + return nil + case .login: + return nil + case .autoLogin: + return nil + case .fetchAchievementList(let requestValue): + return requestValue + } } var bodyParameters: Encodable? { @@ -54,8 +63,8 @@ extension MotiAPI { return requestValue case .autoLogin(let requestValue): return requestValue - case .fetchAchievementList(let requestValue): - return requestValue + case .fetchAchievementList: + return nil } } From 434e148a643466fbe79522c5bc52956b7a1701b8 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 19:31:57 +0900 Subject: [PATCH 102/188] =?UTF-8?q?[iOS]=20refactor:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=AF=B8=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Mock/MockAchievementListRepository.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift index 4cbc48c4..58e3e189 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift @@ -87,11 +87,12 @@ public struct MockAchievementListRepository: AchievementListRepositoryProtocol { } """ - guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } - let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) - - guard let achievementListResponseDataDTO = achievementListResponseDTO.data else { throw NetworkError.decode } - guard let achievementSimpleDTOs = achievementListResponseDataDTO.data else { throw NetworkError.decode } - return achievementSimpleDTOs.map { Achievement(dto: $0) } +// guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } +// let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) +// +// guard let achievementListResponseDataDTO = achievementListResponseDTO.data else { throw NetworkError.decode } +// guard let achievementSimpleDTOs = achievementListResponseDataDTO.data else { throw NetworkError.decode } +// return achievementSimpleDTOs.map { Achievement(dto: $0) } + return [] } } From 85dc5ffc0e1443d3885aba1944cf4049ad1e5fab Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 19:32:17 +0900 Subject: [PATCH 103/188] =?UTF-8?q?[iOS]=20feat:=20Achievement=20init=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Domain/Entity/Achievement.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index 9f9fc4d7..849020ee 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -12,11 +12,19 @@ public struct Achievement: Hashable { public let category: String public let title: String public let imageURL: URL? - public let body: String + public let body: String? public let achieveCount: Int public let date: Date? - public init(id: Int, category: String = "", title: String = "", imageURL: URL? = nil, body: String = "", achieveCount: Int = 0, date: Date? = nil) { + public init( + id: Int, + category: String, + title: String, + imageURL: URL?, + body: String?, + achieveCount: Int, + date: Date? + ) { self.id = id self.category = category self.title = title @@ -25,4 +33,14 @@ public struct Achievement: Hashable { self.achieveCount = achieveCount self.date = date } + + public init(id: Int, title: String, imageURL: URL?) { + self.id = id + self.category = "" + self.title = title + self.imageURL = imageURL + self.body = "" + self.achieveCount = 0 + self.date = nil + } } From a8cc1eefcc73e9092e66dcf1d8eb9677d8c47d6d Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Tue, 21 Nov 2023 19:42:02 +0900 Subject: [PATCH 104/188] =?UTF-8?q?[iOS]=20refactor:=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EC=8B=9C=20=EC=A0=95=ED=94=BC=EC=85=94=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- iOS/moti/moti/Presentation/Package.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/iOS/moti/moti/Presentation/Package.swift b/iOS/moti/moti/Presentation/Package.swift index 56e9d086..527b5dd8 100644 --- a/iOS/moti/moti/Presentation/Package.swift +++ b/iOS/moti/moti/Presentation/Package.swift @@ -16,7 +16,8 @@ let package = Package( .package(path: "../Design"), .package(path: "../Core"), .package(path: "../Domain"), - .package(path: "../Data") + .package(path: "../Data"), + .package(url: "https://github.com/jeongju9216/Jeongfisher.git", from: "2.5.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -27,7 +28,8 @@ let package = Package( .product(name: "Design", package: "Design"), .product(name: "Core", package: "Core"), .product(name: "Domain", package: "Domain"), - .product(name: "Data", package: "Data") + .product(name: "Data", package: "Data"), + "Jeongfisher" ], path: "Sources"), .testTarget( From 255287cc2ccdb355a211637ec8a4e31c20c09a4d Mon Sep 17 00:00:00 2001 From: lsh23 Date: Tue, 21 Nov 2023 23:38:09 +0900 Subject: [PATCH 105/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20dto?= =?UTF-8?q?=EC=99=80=20=ED=95=84=EC=9A=94=ED=95=9C=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/achievement-detail-response.ts | 23 ++++++++++++++++--- BE/src/achievement/index.ts | 11 +++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/BE/src/achievement/dto/achievement-detail-response.ts b/BE/src/achievement/dto/achievement-detail-response.ts index 6d519feb..dd76b9cb 100644 --- a/BE/src/achievement/dto/achievement-detail-response.ts +++ b/BE/src/achievement/dto/achievement-detail-response.ts @@ -1,9 +1,26 @@ +import { IAchievementDetail } from '../index'; + export class AchievementDetailResponse { id: number; imageUrl: string; title: string; content: string; - categoryName: string; - categoryRoundCnt: string; - createdAt: string; + createdAt: Date; + category: { + id: number; + name: string; + round: number; + }; + constructor(achievementDetail: IAchievementDetail) { + this.id = achievementDetail.id; + this.title = achievementDetail.title; + this.content = achievementDetail.content; + this.imageUrl = achievementDetail.imageUrl; + this.createdAt = new Date(achievementDetail.createdAt); + this.category = { + id: achievementDetail.categoryId, + name: achievementDetail.categoryName, + round: Number(achievementDetail.round), + }; + } } diff --git a/BE/src/achievement/index.ts b/BE/src/achievement/index.ts index f8243e60..aefc38ec 100644 --- a/BE/src/achievement/index.ts +++ b/BE/src/achievement/index.ts @@ -3,3 +3,14 @@ export interface Next { take?: number; categoryId?: number; } + +export interface IAchievementDetail { + id: number; + title: string; + content: string; + imageUrl: string; + createdAt: Date; + categoryId: number; + categoryName: string; + round: number; +} From 0fb60acbeefaee4f6c2ba36b00e7cc346c429c73 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Tue, 21 Nov 2023 23:46:59 +0900 Subject: [PATCH 106/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EC=9D=84=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20query=20builder=EB=A1=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/achievement.repository.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts index 014a99f6..09576b6e 100644 --- a/BE/src/achievement/entities/achievement.repository.ts +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -4,6 +4,8 @@ import { AchievementEntity } from './achievement.entity'; import { Achievement } from '../domain/achievement.domain'; import { FindOptionsWhere, LessThan } from 'typeorm'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; +import { AchievementDetailResponse } from '../dto/achievement-detail-response'; +import { IAchievementDetail } from '../index'; @CustomRepository(AchievementEntity) export class AchievementRepository extends TransactionalRepository { @@ -35,4 +37,33 @@ export class AchievementRepository extends TransactionalRepository(); + + if (result.id) { + return new AchievementDetailResponse(result); + } + return null; + } } From b41d3682f41c8a3fb0fe7f29208e0b380486ae63 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Tue, 21 Nov 2023 23:47:31 +0900 Subject: [PATCH 107/188] =?UTF-8?q?[BE]=20test:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=EC=97=90=20=EA=B4=80?= =?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/achievement.repository.spec.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/BE/src/achievement/entities/achievement.repository.spec.ts b/BE/src/achievement/entities/achievement.repository.spec.ts index 50705414..f2eb5c6c 100644 --- a/BE/src/achievement/entities/achievement.repository.spec.ts +++ b/BE/src/achievement/entities/achievement.repository.spec.ts @@ -169,4 +169,82 @@ describe('AchievementRepository test', () => { expect(findAll.length).toEqual(12); }); }); + + test('달성 기록 상세 정보를 조회한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + + const achievements: Achievement[] = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + + // when + const achievementDetail = + await achievementRepository.findAchievementDetail( + user.id, + achievements[5].id, + ); + + // then + expect(achievementDetail.id).toBeDefined(); + expect(achievementDetail.title).toBeDefined(); + expect(achievementDetail.content).toBeDefined(); + expect(achievementDetail.imageUrl).toBeDefined(); + expect(achievementDetail.category.id).toEqual(category.id); + expect(achievementDetail.category.name).toEqual(category.name); + expect(achievementDetail.category.round).toEqual(6); + }); + }); + + test('자신이 소유하지 않은 달성 기록 정보를 조회하면 null을 반환한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + + const achievements: Achievement[] = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + + // when + const achievementDetail = + await achievementRepository.findAchievementDetail( + user.id + 1, + achievements[5].id, + ); + + // then + expect(achievementDetail).toBeNull(); + }); + }); + + test('유효하지 않은 달성 기록 id를 통해 조회하면 null을 반환한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + + const achievements: Achievement[] = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + + // when + const achievementDetail = + await achievementRepository.findAchievementDetail(user.id + 1, 100); + + // then + expect(achievementDetail).toBeNull(); + }); + }); }); From f1c54984b6cd53423ecc35599f7298045118db66 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 00:02:55 +0900 Subject: [PATCH 108/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A5=BC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/achievement.service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/BE/src/achievement/application/achievement.service.ts b/BE/src/achievement/application/achievement.service.ts index 0214caeb..304b5c76 100644 --- a/BE/src/achievement/application/achievement.service.ts +++ b/BE/src/achievement/application/achievement.service.ts @@ -3,6 +3,9 @@ import { AchievementRepository } from '../entities/achievement.repository'; import { AchievementResponse } from '../dto/achievement-response'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; import { PaginateAchievementResponse } from '../dto/paginate-achievement-response'; +import { AchievementDetailResponse } from '../dto/achievement-detail-response'; +import { Transactional } from '../../config/transaction-manager'; +import { NoSuchAchievementException } from '../exception/no-such-achievement.exception'; @Injectable() export class AchievementService { @@ -20,4 +23,17 @@ export class AchievementService { achievements.map((achievement) => AchievementResponse.from(achievement)), ); } + + @Transactional({ readonly: true }) + async getAchievementDetail(userId: number, achievementId: number) { + const achievement: AchievementDetailResponse = + await this.achievementRepository.findAchievementDetail( + userId, + achievementId, + ); + if (!achievement) { + throw new NoSuchAchievementException(); + } + return achievement; + } } From 61cf004f3bdb19ffd5addd8030c7b5f79d7992d9 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 00:03:20 +0900 Subject: [PATCH 109/188] =?UTF-8?q?[BE]=20test:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A0=88=EC=9D=B4=EC=96=B4=EC=9D=98=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/achievement.service.spec.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/BE/src/achievement/application/achievement.service.spec.ts b/BE/src/achievement/application/achievement.service.spec.ts index bfaad970..754d5cea 100644 --- a/BE/src/achievement/application/achievement.service.spec.ts +++ b/BE/src/achievement/application/achievement.service.spec.ts @@ -13,6 +13,7 @@ import { AchievementFixture } from '../../../test/achievement/achievement-fixtur import { CategoryTestModule } from '../../../test/category/category-test.module'; import { AchievementTestModule } from '../../../test/achievement/achievement-test.module'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; +import { NoSuchAchievementException } from '../exception/no-such-achievement.exception'; describe('AchievementService Test', () => { let achievementService: AchievementService; @@ -88,4 +89,63 @@ describe('AchievementService Test', () => { expect(lastResponse.data.length).toEqual(2); expect(lastResponse.next).toEqual(null); }); + + test('달성 기록 상세정보를 조회를 할 수 있다.', async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + const achievements = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + // when + const detail = await achievementService.getAchievementDetail( + user.id, + achievements[7].id, + ); + + expect(detail.id).toBeDefined(); + expect(detail.title).toBeDefined(); + expect(detail.content).toBeDefined(); + expect(detail.imageUrl).toBeDefined(); + expect(detail.category.id).toEqual(category.id); + expect(detail.category.name).toEqual(category.name); + expect(detail.category.round).toEqual(8); + }); + + test('자신이 소유하지 않은 달성 기록 정보를 조회하면 NoSuchAchievementException을 던진다.', async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + const achievements = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + // when + // then + await expect( + achievementService.getAchievementDetail(user.id + 1, achievements[7].id), + ).rejects.toThrow(NoSuchAchievementException); + }); + + test('유효하지 않은 달성 기록 id를 통해 조회하면 NoSuchAchievementException를 던진다.', async () => { + // given + const user = await usersFixture.getUser('ABC'); + const category = await categoryFixture.getCategory(user, 'ABC'); + const achievements = []; + for (let i = 0; i < 10; i++) { + achievements.push( + await achievementFixture.getAchievement(user, category), + ); + } + // when + // then + await expect( + achievementService.getAchievementDetail(user.id, achievements[9].id + 1), + ).rejects.toThrow(NoSuchAchievementException); + }); }); From 1a7c6f502149ec35ea28e7cc749c983563168e53 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 00:04:18 +0900 Subject: [PATCH 110/188] =?UTF-8?q?[BE]=20feat:=20NoSuchAchievementExcepti?= =?UTF-8?q?on=20=EC=97=90=EB=9F=AC=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/no-such-achievement.exception.ts | 8 ++++++++ BE/src/common/exception/error-code.ts | 4 ++++ 2 files changed, 12 insertions(+) create mode 100644 BE/src/achievement/exception/no-such-achievement.exception.ts diff --git a/BE/src/achievement/exception/no-such-achievement.exception.ts b/BE/src/achievement/exception/no-such-achievement.exception.ts new file mode 100644 index 00000000..ba11d85e --- /dev/null +++ b/BE/src/achievement/exception/no-such-achievement.exception.ts @@ -0,0 +1,8 @@ +import { MotimateException } from '../../common/exception/motimate.excpetion'; +import { ERROR_INFO } from '../../common/exception/error-code'; + +export class NoSuchAchievementException extends MotimateException { + constructor() { + super(ERROR_INFO.NO_SUCH_ACHIEVEMENT); + } +} diff --git a/BE/src/common/exception/error-code.ts b/BE/src/common/exception/error-code.ts index d7693ba6..0e7490e7 100644 --- a/BE/src/common/exception/error-code.ts +++ b/BE/src/common/exception/error-code.ts @@ -35,4 +35,8 @@ export const ERROR_INFO = { statusCode: 400, message: '관리자 승인 대기중인 사용자가 아닙니다.', }, + NO_SUCH_ACHIEVEMENT: { + statusCode: 404, + message: '존재하지 않는 달성기록 입니다.', + }, } as const; From e18c6184420a4538fed8c145f294f19d012f370f Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 00:20:43 +0900 Subject: [PATCH 111/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20endpoint=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/achievement.controller.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/BE/src/achievement/controller/achievement.controller.ts b/BE/src/achievement/controller/achievement.controller.ts index 63f925fe..b0d79a68 100644 --- a/BE/src/achievement/controller/achievement.controller.ts +++ b/BE/src/achievement/controller/achievement.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Param, + ParseIntPipe, + Query, + UseGuards, +} from '@nestjs/common'; import { AchievementService } from '../application/achievement.service'; import { AccessTokenGuard } from '../../auth/guard/access-token.guard'; import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decorator'; @@ -7,6 +14,7 @@ import { ApiData } from '../../common/api/api-data'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PaginateAchievementResponse } from '../dto/paginate-achievement-response'; +import { IsNumber } from 'class-validator'; @Controller('/api/v1/achievements') @ApiTags('달성기록 API') @@ -33,4 +41,17 @@ export class AchievementController { ); return ApiData.success(response); } + + @Get('/:id') + @UseGuards(AccessTokenGuard) + async getAchievement( + @AuthenticatedUser() user: User, + @Param('id', ParseIntPipe) id: number, + ) { + const response = await this.achievementService.getAchievementDetail( + user.id, + id, + ); + return ApiData.success(response); + } } From cf1385e8e268766bdd628e4d138966daa70816d4 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 00:35:30 +0900 Subject: [PATCH 112/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20API?= =?UTF-8?q?=20API=20=EA=B4=80=EB=A0=A8=20swagger=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 응답에 대한 속성 추가 중에 categoryInfo라는 클래스 추가 도출 --- .../controller/achievement.controller.ts | 18 +++++++++++-- .../dto/achievement-detail-response.ts | 25 +++++++++++-------- BE/src/achievement/dto/category-info.ts | 15 +++++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 BE/src/achievement/dto/category-info.ts diff --git a/BE/src/achievement/controller/achievement.controller.ts b/BE/src/achievement/controller/achievement.controller.ts index b0d79a68..eab492e1 100644 --- a/BE/src/achievement/controller/achievement.controller.ts +++ b/BE/src/achievement/controller/achievement.controller.ts @@ -12,9 +12,14 @@ import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decora import { User } from '../../users/domain/user.domain'; import { ApiData } from '../../common/api/api-data'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; -import { ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiCreatedResponse, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { PaginateAchievementResponse } from '../dto/paginate-achievement-response'; -import { IsNumber } from 'class-validator'; +import { AchievementDetailResponse } from '../dto/achievement-detail-response'; @Controller('/api/v1/achievements') @ApiTags('달성기록 API') @@ -44,6 +49,15 @@ export class AchievementController { @Get('/:id') @UseGuards(AccessTokenGuard) + @ApiOperation({ + summary: '달성기록 상세정보 API', + description: '달성기록 리스트를 커서 페이지네이션 기반으로 조회한다.', + }) + @ApiResponse({ + status: 200, + description: '달성기록 상세정보', + type: AchievementDetailResponse, + }) async getAchievement( @AuthenticatedUser() user: User, @Param('id', ParseIntPipe) id: number, diff --git a/BE/src/achievement/dto/achievement-detail-response.ts b/BE/src/achievement/dto/achievement-detail-response.ts index dd76b9cb..bc583760 100644 --- a/BE/src/achievement/dto/achievement-detail-response.ts +++ b/BE/src/achievement/dto/achievement-detail-response.ts @@ -1,26 +1,31 @@ import { IAchievementDetail } from '../index'; +import { ApiProperty } from '@nestjs/swagger'; +import { CategoryInfo } from './category-info'; export class AchievementDetailResponse { + // @ApiProperty({ type: [AchievementResponse], description: 'data' }) + @ApiProperty({ description: 'id' }) id: number; + @ApiProperty({ description: 'imageUrl' }) imageUrl: string; + @ApiProperty({ description: 'title' }) title: string; + @ApiProperty({ description: 'content' }) content: string; + @ApiProperty({ description: 'createdAt' }) createdAt: Date; - category: { - id: number; - name: string; - round: number; - }; + @ApiProperty({ type: CategoryInfo, description: 'data' }) + category: CategoryInfo; constructor(achievementDetail: IAchievementDetail) { this.id = achievementDetail.id; this.title = achievementDetail.title; this.content = achievementDetail.content; this.imageUrl = achievementDetail.imageUrl; this.createdAt = new Date(achievementDetail.createdAt); - this.category = { - id: achievementDetail.categoryId, - name: achievementDetail.categoryName, - round: Number(achievementDetail.round), - }; + this.category = new CategoryInfo( + achievementDetail.categoryId, + achievementDetail.categoryName, + Number(achievementDetail.round), + ); } } diff --git a/BE/src/achievement/dto/category-info.ts b/BE/src/achievement/dto/category-info.ts new file mode 100644 index 00000000..630512fa --- /dev/null +++ b/BE/src/achievement/dto/category-info.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CategoryInfo { + @ApiProperty({ description: 'id' }) + id: number; + @ApiProperty({ description: 'name' }) + name: string; + @ApiProperty({ description: '회차 수' }) + round: number; + constructor(id: number, name: string, round: number) { + this.id = id; + this.name = name; + this.round = round; + } +} From 0c8c428dd96a18823028c27e3c983215854ff161 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 00:37:07 +0900 Subject: [PATCH 113/188] =?UTF-8?q?[BE]=20fix:=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=98=EC=96=B4=EC=9E=88=EB=8D=98=20swagger=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../achievement/controller/achievement.controller.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/BE/src/achievement/controller/achievement.controller.ts b/BE/src/achievement/controller/achievement.controller.ts index eab492e1..bbb81a1e 100644 --- a/BE/src/achievement/controller/achievement.controller.ts +++ b/BE/src/achievement/controller/achievement.controller.ts @@ -12,12 +12,7 @@ import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decora import { User } from '../../users/domain/user.domain'; import { ApiData } from '../../common/api/api-data'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; -import { - ApiCreatedResponse, - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { PaginateAchievementResponse } from '../dto/paginate-achievement-response'; import { AchievementDetailResponse } from '../dto/achievement-detail-response'; @@ -32,7 +27,8 @@ export class AchievementController { summary: '달성기록 리스트 API', description: '달성기록 리스트를 커서 페이지네이션 기반으로 조회한다.', }) - @ApiCreatedResponse({ + @ApiResponse({ + status: 200, description: '달성기록 리스트', type: PaginateAchievementResponse, }) From c7d2eba0992b6543721b7c75544e39c2724b6c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 12:45:39 +0900 Subject: [PATCH 114/188] =?UTF-8?q?[iOS]=20feat:=20Entity,=20DTO=20URL=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/DTO/AchievementListDTO.swift | 4 ++-- .../Sources/Domain/Entity/Achievement.swift | 14 +++++++------- .../Presentation/Home/HomeViewController.swift | 18 ++++++++++++++---- .../Presentation/Home/HomeViewModel.swift | 4 ++++ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift index 814f90ab..84bb2163 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/AchievementListDTO.swift @@ -28,7 +28,7 @@ struct AchievementListResponseNextDTO: Codable { struct AchievementSimpleDTO: Codable { let id: Int? - let thumbnailUrl: String? + let thumbnailUrl: URL? let title: String? } @@ -37,7 +37,7 @@ extension Achievement { self.init( id: dto.id ?? -1, title: dto.title ?? "", - imageURL: URL(string: dto.thumbnailUrl ?? "") + imageURL: dto.thumbnailUrl ) } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index 849020ee..1444f26a 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -9,11 +9,11 @@ import Foundation public struct Achievement: Hashable { public let id: Int - public let category: String + public let category: String? public let title: String public let imageURL: URL? public let body: String? - public let achieveCount: Int + public let achieveCount: Int? public let date: Date? public init( @@ -21,9 +21,9 @@ public struct Achievement: Hashable { category: String, title: String, imageURL: URL?, - body: String?, + body: String, achieveCount: Int, - date: Date? + date: Date ) { self.id = id self.category = category @@ -36,11 +36,11 @@ public struct Achievement: Hashable { public init(id: Int, title: String, imageURL: URL?) { self.id = id - self.category = "" + self.category = nil self.title = title self.imageURL = imageURL - self.body = "" - self.achieveCount = 0 + self.body = nil + self.achieveCount = nil self.date = nil } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 85241661..49caddf0 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -62,7 +62,7 @@ final class HomeViewController: BaseViewController { title: "추가할 카테고리 이름을 입력하세요.", okTitle: "생성", placeholder: "카테고리 이름은 최대 10글자입니다." - ) { (text) in + ) { text in Logger.debug(text) } @@ -122,13 +122,23 @@ final class HomeViewController: BaseViewController { extension HomeViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard let cell = collectionView.cellForItem(at: indexPath) as? CategoryCollectionViewCell else { return } - + + // 카테고리 셀을 눌렀을 때 + if let cell = collectionView.cellForItem(at: indexPath) as? CategoryCollectionViewCell { + categoryCellDidSelected(cell: cell) + } else if let cell = collectionView.cellForItem(at: indexPath) as? AchievementCollectionViewCell { + // 달성 기록 리스트 셀을 눌렀을 때 + // TODO: 상세 정보 화면으로 이동 + Logger.debug("clicked: \(viewModel.findAchievement(at: indexPath.row).title)") + } + } + + private func categoryCellDidSelected(cell: CategoryCollectionViewCell) { // 눌렸을 때 Bounce 적용 // Highlight에만 적용하면 Select에서는 적용이 안 되서 별도로 적용함 UIView.animate(withDuration: 0.08, animations: { cell.applyHighlightUI() - let scale = CGAffineTransform(scaleX: 0.97, y: 0.97) + let scale = CGAffineTransform(scaleX: 0.95, y: 0.95) cell.transform = scale }, completion: { _ in cell.transform = .identity diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 691b81c0..a7b90c34 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -82,6 +82,10 @@ final class HomeViewModel { self.achievementDataSource = dataSource } + func findAchievement(at index: Int) -> Achievement { + return achievements[index] + } + private func fetchCategories() { categoryDataSource?.update(data: categories) } From 311cb2b40aa71b7a3292ea04ffbf69d6ed9b8909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 13:22:53 +0900 Subject: [PATCH 115/188] =?UTF-8?q?[iOS]=20feat:=20Category=20Entity,=20DT?= =?UTF-8?q?O=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/DTO/CategoryListDTO.swift | 33 +++++++++++++++++++ .../Data/Network/Endpoint/MotiAPI.swift | 15 ++++++--- .../Sources/Domain/Entity/Category.swift | 27 +++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift create mode 100644 iOS/moti/moti/Domain/Sources/Domain/Entity/Category.swift diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift new file mode 100644 index 00000000..6fe7b36b --- /dev/null +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift @@ -0,0 +1,33 @@ +// +// CategoryListDTO.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import Foundation +import Domain + +struct CategoryListDTO: ResponseDataDTO { + let success: Bool? + let message: String? + let data: [CategoryDTO]? +} + +struct CategoryDTO: Codable { + let id: Int? + let name: String? + let continued: Int? + let lastChallanged: Date? +} + +extension Domain.Category { + init(dto: CategoryDTO) { + self.init( + id: dto.id ?? -1, + name: dto.name ?? "", + continued: dto.continued ?? -1, + lastChallenged: dto.lastChallanged ?? .now + ) + } +} diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift index bb1b2173..bb61730b 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift @@ -13,6 +13,7 @@ enum MotiAPI: EndpointProtocol { case login(requestValue: LoginRequestValue) case autoLogin(requestValue: AutoLoginRequestValue) case fetchAchievementList(requestValue: FetchAchievementListRequestValue?) + case fetchCategoryList } extension MotiAPI { @@ -21,7 +22,7 @@ extension MotiAPI { } var baseURL: String { - return Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as! String + "/api/v1" + return Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as! String + "/api/\(version)" } var path: String { @@ -30,6 +31,7 @@ extension MotiAPI { case .login: return "/auth/login" case .autoLogin: return "/auth/refresh" case .fetchAchievementList: return "/achievements" + case .fetchCategoryList: return "/categories"" } } @@ -39,6 +41,7 @@ extension MotiAPI { case .login: return .post case .autoLogin: return .post case .fetchAchievementList: return .get + case .fetchCategoryList: return .get } } @@ -52,6 +55,8 @@ extension MotiAPI { return nil case .fetchAchievementList(let requestValue): return requestValue + case .fetchCategoryList: + return nil } } @@ -65,6 +70,8 @@ extension MotiAPI { return requestValue case .fetchAchievementList: return nil + case .fetchCategoryList: + return nil } } @@ -72,11 +79,9 @@ extension MotiAPI { var header = ["Content-Type": "application/json"] switch self { - case .version: - break - case .login: + case .version, .login: break - case .autoLogin, .fetchAchievementList: + case .autoLogin, .fetchAchievementList, .fetchCategoryList: // TODO: Keychain Storage로 변경 if let accessToken = UserDefaults.standard.string(forKey: "accessToken") { header["Authorization"] = "Bearer \(accessToken)" diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Category.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Category.swift new file mode 100644 index 00000000..b9d6d7f7 --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Category.swift @@ -0,0 +1,27 @@ +// +// Category.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import Foundation + +public struct Category: Hashable { + public let id: Int + public let name: String + public let continued: Int + public let lastChallenged: Date + + public init( + id: Int, + name: String, + continued: Int, + lastChallenged: Date + ) { + self.id = id + self.name = name + self.continued = continued + self.lastChallenged = lastChallenged + } +} From ac1ee5ab21b021d8f1118a97acb223450ffbf463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 13:43:20 +0900 Subject: [PATCH 116/188] =?UTF-8?q?[iOS]=20feat:=20CategoryList=20API,=20R?= =?UTF-8?q?epository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- iOS/moti/moti/Data/Package.swift | 6 ++++- .../Data/Network/DTO/CategoryListDTO.swift | 2 +- .../Data/Network/Endpoint/MotiAPI.swift | 2 +- .../Repository/CategoryListRepository.swift | 25 +++++++++++++++++++ .../CategoryListRepositoryProtocol.swift | 12 +++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift create mode 100644 iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift diff --git a/iOS/moti/moti/Data/Package.swift b/iOS/moti/moti/Data/Package.swift index 651725d6..12c93257 100644 --- a/iOS/moti/moti/Data/Package.swift +++ b/iOS/moti/moti/Data/Package.swift @@ -28,6 +28,10 @@ let package = Package( path: "Sources"), .testTarget( name: "DataTests", - dependencies: ["Data"]) + dependencies: [ + "Data", + .product(name: "Domain", package: "Domain") + ] + ) ] ) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift index 6fe7b36b..e519bcae 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift @@ -8,7 +8,7 @@ import Foundation import Domain -struct CategoryListDTO: ResponseDataDTO { +struct CategoryListResponseDTO: ResponseDataDTO { let success: Bool? let message: String? let data: [CategoryDTO]? diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift index bb61730b..787d17f7 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift @@ -31,7 +31,7 @@ extension MotiAPI { case .login: return "/auth/login" case .autoLogin: return "/auth/refresh" case .fetchAchievementList: return "/achievements" - case .fetchCategoryList: return "/categories"" + case .fetchCategoryList: return "/categories" } } diff --git a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift new file mode 100644 index 00000000..35194553 --- /dev/null +++ b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift @@ -0,0 +1,25 @@ +// +// File.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import Foundation +import Domain + +public struct CategoryListRepository: CategoryListRepositoryProtocol { + private let provider: ProviderProtocol + + init(provider: ProviderProtocol = Provider()) { + self.provider = provider + } + + public func fetchCategoryList() async throws -> [Domain.Category] { + let endpoint = MotiAPI.fetchCategoryList + let responseDTO = try await provider.request(with: endpoint, type: CategoryListResponseDTO.self) + + guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } + return categoryDTO.map { Category(dto: $0) } + } +} diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift new file mode 100644 index 00000000..e1db2661 --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift @@ -0,0 +1,12 @@ +// +// CategoryListRepositoryProtocol.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import Foundation + +public protocol CategoryListRepositoryProtocol { + func fetchCategoryList() async throws -> [Category] +} From ede39176c462bee74913f2f47ac9d301145338fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 13:43:35 +0900 Subject: [PATCH 117/188] =?UTF-8?q?[iOS]=20test:=20CategoryListRepository?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CategoryListRepositoryTests.swift | 34 +++++++ .../Mock/MockAchievementListRepository.swift | 17 ++-- .../Mock/MockCategoryListRepository.swift | 40 +++++++++ .../Repository/Mock/MockLoginRepository.swift | 5 +- .../Mock/MockVersionRepository.swift | 3 +- .../NetworkLoginRepositoryTests.swift | 90 +++++++++---------- 6 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift create mode 100644 iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift new file mode 100644 index 00000000..8c2f7d7e --- /dev/null +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift @@ -0,0 +1,34 @@ +// +// MockCategoryListRepository.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import XCTest + +final class CategoryListRepositoryTests: 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 test_형식에_맞는_json을_디코딩하면_카테고리_리스트_반환() throws { + let repository = MockCategoryListRepository() + let expectation = XCTestExpectation(description: "test_형식에_맞는_json을_디코딩하면_카테고리_리스트_반환") + + Task { + let result = try await repository.fetchCategoryList() + XCTAssertEqual(result.count, 2) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3) + } + +} diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift index 58e3e189..25679878 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift @@ -6,8 +6,8 @@ // import Foundation -import Domain -import Data +@testable import Domain +@testable import Data public struct MockAchievementListRepository: AchievementListRepositoryProtocol { public init() { } @@ -87,12 +87,11 @@ public struct MockAchievementListRepository: AchievementListRepositoryProtocol { } """ -// guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } -// let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) -// -// guard let achievementListResponseDataDTO = achievementListResponseDTO.data else { throw NetworkError.decode } -// guard let achievementSimpleDTOs = achievementListResponseDataDTO.data else { throw NetworkError.decode } -// return achievementSimpleDTOs.map { Achievement(dto: $0) } - return [] + guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } + let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) + + guard let achievementListResponseDataDTO = achievementListResponseDTO.data else { throw NetworkError.decode } + guard let achievementSimpleDTOs = achievementListResponseDataDTO.data else { throw NetworkError.decode } + return achievementSimpleDTOs.map { Achievement(dto: $0) } } } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift new file mode 100644 index 00000000..12548ab6 --- /dev/null +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift @@ -0,0 +1,40 @@ +// +// File.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import Foundation +@testable import Domain +@testable import Data + +struct MockCategoryListRepository: CategoryListRepositoryProtocol { + private let json = """ + { + "success": true, + "data": [ + { + "id": 0, + "name": "전체", + "continued": 100, + "lastChallenged": "2023-11-08T10:20:10.0202" + }, + { + "id": 1000, + "name": "다이어트", + "continued": 32, + "lastChallenged": "2023-11-08T10:20:10.0202" + } + ] + } + """ + + public func fetchCategoryList() async throws -> [Domain.Category] { + guard let testData = json.data(using: .utf8) else { return [] } + let responseDTO = try JSONDecoder().decode(CategoryListResponseDTO.self, from: testData) + + guard let categoryDTO = responseDTO.data else { return [] } + return categoryDTO.map { Category(dto: $0) } + } +} diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift index 3bf9b61d..117d80d8 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift @@ -6,8 +6,9 @@ // import Foundation -import Domain -import Core +@testable import Domain +@testable import Data +@testable import Core public struct MockLoginRepository: LoginRepositoryProtocol { private var json = """ diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockVersionRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockVersionRepository.swift index 77b8bc74..dfcfdca1 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockVersionRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockVersionRepository.swift @@ -6,7 +6,8 @@ // import Foundation -import Domain +@testable import Domain +@testable import Data public struct MockVersionRepository: VersionRepositoryProtocol { diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift index 86f7c94b..bec2cf4a 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/NetworkLoginRepositoryTests.swift @@ -10,48 +10,48 @@ import XCTest @testable import Domain // XCConfig 값을 어떻게 가져올지 고민 -final class NetworkLoginRepositoryTests: XCTestCase { - - private let loginRequestValue = LoginRequestValue(identityToken: "eyJraWQiOiJZdXlYb1kiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoia3IuY29kZXNxdWFkLmJvb3N0Y2FtcDgubW90aSIsImV4cCI6MTcwMDAzOTM5NywiaWF0IjoxNjk5OTUyOTk3LCJzdWIiOiIwMDEzMDYuYTAwZTI5ZGU4N2IyNDgwOGI5N2FiMjlhMDhlMjc3MjAuMTE0MiIsImNfaGFzaCI6ImpoZy05RGN5YzZ1WDZYU0RSNXByQUEiLCJhdXRoX3RpbWUiOjE2OTk5NTI5OTcsIm5vbmNlX3N1cHBvcnRlZCI6dHJ1ZX0.ppateLL5OGjQhm9SSWFPASKaTJrVQZrkDbqB_crWkapEuTJpZN7-M63STtNXOwzRPoAR5TA0U1M7idXU9-5UYO7-2B81rquSAA4t5lrtQJAF5ly1hUtlIIfl7_IDemm28r7c5-WqeqQ24hNDcPVM8zMC11s1aK6M_IGVeGq_jWmliW5GHPFr_RpCSV3kz6BcVqd7055n7aIx8eWb5gGGh3s14wtg7HeEpbg-iDEHqkAAOFyadB8b0CL66OeoBWogsZdS2JfQwD_jVDafn9uhh7jOae8d-XFyfePRwUAT5360LQlmVshA2nqlAJfNMDdhqG0VysrjpSOtA0reLiockg") - private let autoLoginRequestValue = AutoLoginRequestValue(refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyQ29kZSI6Ilk0Q0Y1NDciLCJpYXQiOjE3MDAwNDUzOTMsImV4cCI6MTcwMDY1MDE5M30.H6t0xZyK0OlFurEv9XO25D6ggwdUscIBd5LY4gFXC3g") - - 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. - } - - // TODO: xcconfig baseURL nil error - func test_login을_요청하면_결과값이_존재함() throws { - let repository = LoginRepository() - let expectation = XCTestExpectation(description: "test_login을_요청하면_결과값이_존재함") - - Task { - let result = try await repository.login(requestValue: loginRequestValue) - - XCTAssertNotNil(result) - - expectation.fulfill() - } - - wait(for: [expectation], timeout: 3) - } - - func test_유효한토큰으로_autologin을_요청하면_결과값이_존재함() throws { - let repository = MockLoginRepository() - let expectation = XCTestExpectation(description: "test_유효한토큰으로_autologin을_요청하면_결과값이_존재함") - - Task { - let result = try await repository.autoLogin(requestValue: autoLoginRequestValue) - - XCTAssertNotNil(result) - - expectation.fulfill() - } - - wait(for: [expectation], timeout: 3) - } - -} +//final class NetworkLoginRepositoryTests: XCTestCase { +// +// private let loginRequestValue = LoginRequestValue(identityToken: "eyJraWQiOiJZdXlYb1kiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoia3IuY29kZXNxdWFkLmJvb3N0Y2FtcDgubW90aSIsImV4cCI6MTcwMDAzOTM5NywiaWF0IjoxNjk5OTUyOTk3LCJzdWIiOiIwMDEzMDYuYTAwZTI5ZGU4N2IyNDgwOGI5N2FiMjlhMDhlMjc3MjAuMTE0MiIsImNfaGFzaCI6ImpoZy05RGN5YzZ1WDZYU0RSNXByQUEiLCJhdXRoX3RpbWUiOjE2OTk5NTI5OTcsIm5vbmNlX3N1cHBvcnRlZCI6dHJ1ZX0.ppateLL5OGjQhm9SSWFPASKaTJrVQZrkDbqB_crWkapEuTJpZN7-M63STtNXOwzRPoAR5TA0U1M7idXU9-5UYO7-2B81rquSAA4t5lrtQJAF5ly1hUtlIIfl7_IDemm28r7c5-WqeqQ24hNDcPVM8zMC11s1aK6M_IGVeGq_jWmliW5GHPFr_RpCSV3kz6BcVqd7055n7aIx8eWb5gGGh3s14wtg7HeEpbg-iDEHqkAAOFyadB8b0CL66OeoBWogsZdS2JfQwD_jVDafn9uhh7jOae8d-XFyfePRwUAT5360LQlmVshA2nqlAJfNMDdhqG0VysrjpSOtA0reLiockg") +// private let autoLoginRequestValue = AutoLoginRequestValue(refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyQ29kZSI6Ilk0Q0Y1NDciLCJpYXQiOjE3MDAwNDUzOTMsImV4cCI6MTcwMDY1MDE5M30.H6t0xZyK0OlFurEv9XO25D6ggwdUscIBd5LY4gFXC3g") +// +// 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. +// } +// +// // TODO: xcconfig baseURL nil error +// func test_login을_요청하면_결과값이_존재함() throws { +// let repository = LoginRepository() +// let expectation = XCTestExpectation(description: "test_login을_요청하면_결과값이_존재함") +// +// Task { +// let result = try await repository.login(requestValue: loginRequestValue) +// +// XCTAssertNotNil(result) +// +// expectation.fulfill() +// } +// +// wait(for: [expectation], timeout: 3) +// } +// +// func test_유효한토큰으로_autologin을_요청하면_결과값이_존재함() throws { +// let repository = MockLoginRepository() +// let expectation = XCTestExpectation(description: "test_유효한토큰으로_autologin을_요청하면_결과값이_존재함") +// +// Task { +// let result = try await repository.autoLogin(requestValue: autoLoginRequestValue) +// +// XCTAssertNotNil(result) +// +// expectation.fulfill() +// } +// +// wait(for: [expectation], timeout: 3) +// } +// +//} From 179acec1ea53e6c4f70ee8a8667b2c3a7fa87bc3 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 13:55:12 +0900 Subject: [PATCH 118/188] =?UTF-8?q?[BE]=20refactor:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 리스트 엘리먼트와, 카테고리 리스트 래퍼를 분리 --- .../dto/category-list-element.response.ts | 45 +++++++++++++++++++ ...se.ts => category-list-legacy.response.ts} | 30 ++----------- ... => category-list-regacy.response.spec.ts} | 16 ++++--- 3 files changed, 60 insertions(+), 31 deletions(-) create mode 100644 BE/src/category/dto/category-list-element.response.ts rename BE/src/category/dto/{category-list.response.ts => category-list-legacy.response.ts} (61%) rename BE/src/category/dto/{category-list.response.spec.ts => category-list-regacy.response.spec.ts} (89%) diff --git a/BE/src/category/dto/category-list-element.response.ts b/BE/src/category/dto/category-list-element.response.ts new file mode 100644 index 00000000..102544e8 --- /dev/null +++ b/BE/src/category/dto/category-list-element.response.ts @@ -0,0 +1,45 @@ +import { CategoryMetaData } from './category-metadata'; + +export class CategoryListElementResponse { + id: number; + name: string; + continued: number; + lastChallenged: string; + + constructor(category: CategoryMetaData) { + this.id = category.categoryId; + this.name = category.categoryName; + this.continued = category.achievementCount; + this.lastChallenged = category.insertedAt?.toISOString() || null; + } + + static totalCategoryElement() { + return new CategoryListElementResponse({ + categoryId: 0, + categoryName: '전체', + insertedAt: null, + achievementCount: 0, + }); + } + + static build( + categoryMetaData: CategoryMetaData[], + ): CategoryListElementResponse[] { + const totalItem = CategoryListElementResponse.totalCategoryElement(); + const categories: CategoryListElementResponse[] = []; + categories.push(totalItem); + + categoryMetaData?.forEach((category) => { + if ( + !totalItem.lastChallenged || + new Date(totalItem.lastChallenged) < category.insertedAt + ) + totalItem.lastChallenged = category.insertedAt.toISOString(); + totalItem.continued += category.achievementCount; + + categories.push(new CategoryListElementResponse(category)); + }); + + return categories; + } +} diff --git a/BE/src/category/dto/category-list.response.ts b/BE/src/category/dto/category-list-legacy.response.ts similarity index 61% rename from BE/src/category/dto/category-list.response.ts rename to BE/src/category/dto/category-list-legacy.response.ts index 42859394..344478cd 100644 --- a/BE/src/category/dto/category-list.response.ts +++ b/BE/src/category/dto/category-list-legacy.response.ts @@ -1,15 +1,16 @@ import { CategoryMetaData } from './category-metadata'; import { ApiProperty } from '@nestjs/swagger'; +import { CategoryListElementResponse } from './category-list-element.response'; -interface CategoryList { +interface CategoryLegacyList { [key: string]: CategoryListElementResponse; } -export class CategoryListResponse { +export class CategoryListLegacyResponse { @ApiProperty({ description: '카테고리 키 리스트' }) categoryNames: string[] = []; @ApiProperty({ description: '카테고리 데이터' }) - categories: CategoryList = {}; + categories: CategoryLegacyList = {}; constructor(categoryMetaData: CategoryMetaData[]) { categoryMetaData = categoryMetaData || []; @@ -32,26 +33,3 @@ export class CategoryListResponse { }); } } - -export class CategoryListElementResponse { - id: number; - name: string; - continued: number; - lastChallenged: string; - - constructor(category: CategoryMetaData) { - this.id = category.categoryId; - this.name = category.categoryName; - this.continued = category.achievementCount; - this.lastChallenged = category.insertedAt?.toISOString() || null; - } - - static totalCategoryElement() { - return new CategoryListElementResponse({ - categoryId: 0, - categoryName: '전체', - insertedAt: null, - achievementCount: 0, - }); - } -} diff --git a/BE/src/category/dto/category-list.response.spec.ts b/BE/src/category/dto/category-list-regacy.response.spec.ts similarity index 89% rename from BE/src/category/dto/category-list.response.spec.ts rename to BE/src/category/dto/category-list-regacy.response.spec.ts index 39f3acdc..655d09a0 100644 --- a/BE/src/category/dto/category-list.response.spec.ts +++ b/BE/src/category/dto/category-list-regacy.response.spec.ts @@ -1,14 +1,16 @@ import { CategoryMetaData } from './category-metadata'; -import { CategoryListResponse } from './category-list.response'; +import { CategoryListLegacyResponse } from './category-list-legacy.response'; -describe('CategoryListResponse', () => { +describe('CategoryListLegacyResponse', () => { describe('생성된 카테고리가 없더라도 응답이 가능하다.', () => { it('카테고리 메타데이터가 빈 배열일 때 응답이 가능하다.', () => { // given const categoryMetaData: CategoryMetaData[] = []; // when - const categoryListResponse = new CategoryListResponse(categoryMetaData); + const categoryListResponse = new CategoryListLegacyResponse( + categoryMetaData, + ); // then expect(categoryListResponse).toBeDefined(); @@ -27,7 +29,9 @@ describe('CategoryListResponse', () => { const categoryMetaData: CategoryMetaData[] = undefined; // when - const categoryListResponse = new CategoryListResponse(categoryMetaData); + const categoryListResponse = new CategoryListLegacyResponse( + categoryMetaData, + ); // then expect(categoryListResponse).toBeDefined(); @@ -64,7 +68,9 @@ describe('CategoryListResponse', () => { ); // when - const categoryListResponse = new CategoryListResponse(categoryMetaData); + const categoryListResponse = new CategoryListLegacyResponse( + categoryMetaData, + ); // then expect(categoryListResponse).toBeDefined(); From 99fb3705f15fcfb3b2c714789df11523105ad3f7 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 13:55:59 +0900 Subject: [PATCH 119/188] =?UTF-8?q?[BE]=20refactor:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=9E=98=ED=95=91=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CategoryListElementResponse[] 으로만 응답 --- BE/src/category/controller/category.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BE/src/category/controller/category.controller.ts b/BE/src/category/controller/category.controller.ts index 20f30036..5414eeaa 100644 --- a/BE/src/category/controller/category.controller.ts +++ b/BE/src/category/controller/category.controller.ts @@ -15,7 +15,7 @@ import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decora import { User } from '../../users/domain/user.domain'; import { AccessTokenGuard } from '../../auth/guard/access-token.guard'; import { CategoryResponse } from '../dto/category.response'; -import { CategoryListResponse } from '../dto/category-list.response'; +import { CategoryListElementResponse } from '../dto/category-list-element.response'; @Controller('/api/v1/categories') @ApiTags('카테고리 API') @@ -49,8 +49,8 @@ export class CategoryController { }) async getCategories( @AuthenticatedUser() user: User, - ): Promise> { + ): Promise> { const categories = await this.categoryService.getCategoriesByUser(user); - return ApiData.success(new CategoryListResponse(categories)); + return ApiData.success(CategoryListElementResponse.build(categories)); } } From 81dcb779406a7805028f17a74ef6a19f50427210 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 13:56:16 +0900 Subject: [PATCH 120/188] =?UTF-8?q?[BE]=20refactor:=20=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/category/category.module.ts | 3 +- .../controller/category-legacy.controller.ts | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 BE/src/category/controller/category-legacy.controller.ts diff --git a/BE/src/category/category.module.ts b/BE/src/category/category.module.ts index 80c4d1e1..77e44ecc 100644 --- a/BE/src/category/category.module.ts +++ b/BE/src/category/category.module.ts @@ -3,9 +3,10 @@ import { CategoryController } from './controller/category.controller'; import { CategoryService } from './application/category.service'; import { CustomTypeOrmModule } from '../config/typeorm/custom-typeorm.module'; import { CategoryRepository } from './entities/category.repository'; +import { CategoryLegacyController } from './controller/category-legacy.controller'; @Module({ - controllers: [CategoryController], + controllers: [CategoryController, CategoryLegacyController], imports: [CustomTypeOrmModule.forCustomRepository([CategoryRepository])], providers: [CategoryService], }) diff --git a/BE/src/category/controller/category-legacy.controller.ts b/BE/src/category/controller/category-legacy.controller.ts new file mode 100644 index 00000000..263a8da8 --- /dev/null +++ b/BE/src/category/controller/category-legacy.controller.ts @@ -0,0 +1,35 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { CategoryService } from '../application/category.service'; +import { AccessTokenGuard } from '../../auth/guard/access-token.guard'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AuthenticatedUser } from '../../auth/decorator/athenticated-user.decorator'; +import { User } from '../../users/domain/user.domain'; +import { ApiData } from '../../common/api/api-data'; +import { CategoryListLegacyResponse } from '../dto/category-list-legacy.response'; + +@Controller('/api/legacy/categories') +@ApiTags('카테고리 API - Legacy') +export class CategoryLegacyController { + constructor(private readonly categoryService: CategoryService) {} + + @Get() + @UseGuards(AccessTokenGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '카테고리 조회 API', + description: + '사용자 본인에 대한 카테고리를 조회합니다.(Legacy)\nAPI 포맷이 변경되었습니다.', + }) + async getCategoriesLegacy( + @AuthenticatedUser() user: User, + ): Promise> { + const categories = await this.categoryService.getCategoriesByUser(user); + return ApiData.success(new CategoryListLegacyResponse(categories)); + } +} From c76636e5cad1ae14cb0b446c6cc5b1aed9642b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 14:01:23 +0900 Subject: [PATCH 121/188] =?UTF-8?q?[iOS]=20refactor:=20Category=20->=20Cat?= =?UTF-8?q?egoryItem=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD.=20ambiguous?= =?UTF-8?q?=EC=97=90=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 본문1 # feat: 새로운 기능 추가 # fix: 버그 수정 # docs: 문서 수정 # test: 테스트 코드 추가 # refactor: 코드 리팩토링 # chore: 빌드 부분 혹은 패키지 매니저 수정사항 # asset: 애셋, 파일 등 추가 --- .../Data/Network/DTO/CategoryListDTO.swift | 2 +- .../Repository/CategoryListRepository.swift | 43 ++++++++++++++--- .../Mock/MockCategoryListRepository.swift | 4 +- .../{Category.swift => CategoryItem.swift} | 4 +- .../CategoryListRepositoryProtocol.swift | 2 +- .../UseCase/FetchCategoryListUseCase.swift | 20 ++++++++ .../Cell/CategoryCollectionViewCell.swift | 5 +- .../Presentation/Home/HomeCoordinator.swift | 5 +- .../Home/HomeViewController.swift | 8 ++-- .../Presentation/Home/HomeViewModel.swift | 46 ++++++++----------- .../TabBar/TabBarCoordinator.swift | 5 +- 11 files changed, 98 insertions(+), 46 deletions(-) rename iOS/moti/moti/Domain/Sources/Domain/Entity/{Category.swift => CategoryItem.swift} (70%) create mode 100644 iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchCategoryListUseCase.swift diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift index e519bcae..d6f8a3c0 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift @@ -21,7 +21,7 @@ struct CategoryDTO: Codable { let lastChallanged: Date? } -extension Domain.Category { +extension Domain.CategoryItem { init(dto: CategoryDTO) { self.init( id: dto.id ?? -1, diff --git a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift index 35194553..3616be92 100644 --- a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift @@ -11,15 +11,44 @@ import Domain public struct CategoryListRepository: CategoryListRepositoryProtocol { private let provider: ProviderProtocol - init(provider: ProviderProtocol = Provider()) { + public init(provider: ProviderProtocol = Provider()) { self.provider = provider } - public func fetchCategoryList() async throws -> [Domain.Category] { - let endpoint = MotiAPI.fetchCategoryList - let responseDTO = try await provider.request(with: endpoint, type: CategoryListResponseDTO.self) - - guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } - return categoryDTO.map { Category(dto: $0) } +// public func fetchCategoryList() async throws -> [CategoryItem] { +// let endpoint = MotiAPI.fetchCategoryList +// let responseDTO = try await provider.request(with: endpoint, type: CategoryListResponseDTO.self) +// +// guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } +// return categoryDTO.map { CategoryItem(dto: $0) } +// } + + private let json = """ + { + "success": true, + "data": [ + { + "id": 0, + "name": "전체", + "continued": 100, + "lastChallenged": "2023-11-08T10:20:10.0202" + }, + { + "id": 1000, + "name": "다이어트", + "continued": 32, + "lastChallenged": "2023-11-08T10:20:10.0202" + } + ] } + """ + + public func fetchCategoryList() async throws -> [CategoryItem] { + guard let testData = json.data(using: .utf8) else { return [] } + let responseDTO = try JSONDecoder().decode(CategoryListResponseDTO.self, from: testData) + + guard let categoryDTO = responseDTO.data else { return [] } + return categoryDTO.map { CategoryItem(dto: $0) } + } + } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift index 12548ab6..0145a3ec 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift @@ -30,11 +30,11 @@ struct MockCategoryListRepository: CategoryListRepositoryProtocol { } """ - public func fetchCategoryList() async throws -> [Domain.Category] { + public func fetchCategoryList() async throws -> [CategoryItem] { guard let testData = json.data(using: .utf8) else { return [] } let responseDTO = try JSONDecoder().decode(CategoryListResponseDTO.self, from: testData) guard let categoryDTO = responseDTO.data else { return [] } - return categoryDTO.map { Category(dto: $0) } + return categoryDTO.map { CategoryItem(dto: $0) } } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Category.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift similarity index 70% rename from iOS/moti/moti/Domain/Sources/Domain/Entity/Category.swift rename to iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift index b9d6d7f7..3025eaa9 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Category.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift @@ -7,7 +7,9 @@ import Foundation -public struct Category: Hashable { +// Category는 이미 있는 타입이라 ambiguous 에러 뜸. (Domain.Category로 사용할 수 있지만 번거로움) +// 그래서 뒤에 Item 붙임 +public struct CategoryItem: Hashable { public let id: Int public let name: String public let continued: Int diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift index e1db2661..f37310f4 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift @@ -8,5 +8,5 @@ import Foundation public protocol CategoryListRepositoryProtocol { - func fetchCategoryList() async throws -> [Category] + func fetchCategoryList() async throws -> [CategoryItem] } diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchCategoryListUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchCategoryListUseCase.swift new file mode 100644 index 00000000..af84e5a0 --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchCategoryListUseCase.swift @@ -0,0 +1,20 @@ +// +// FetchCategoryListUseCase.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import Foundation + +public struct FetchCategoryListUseCase { + private let repository: CategoryListRepositoryProtocol + + public init(repository: CategoryListRepositoryProtocol) { + self.repository = repository + } + + public func execute() async throws -> [CategoryItem] { + return try await repository.fetchCategoryList() + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/CategoryCollectionViewCell.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/CategoryCollectionViewCell.swift index edf988d4..5ec59e35 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/CategoryCollectionViewCell.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/CategoryCollectionViewCell.swift @@ -7,6 +7,7 @@ import UIKit import Design +import Domain final class CategoryCollectionViewCell: UICollectionViewCell { @@ -60,8 +61,8 @@ final class CategoryCollectionViewCell: UICollectionViewCell { } // MARK: - Methods - func configure(with title: String) { - label.text = title + func configure(with category: CategoryItem) { + label.text = category.name } override func applyHighlightUI() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift index 3a1a4afb..7b790ba7 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift @@ -23,7 +23,10 @@ public final class HomeCoordinator: Coordinator { } public func start() { - let homeVM = HomeViewModel(fetchAchievementListUseCase: .init(repository: AchievementListRepository())) + let homeVM = HomeViewModel( + fetchAchievementListUseCase: .init(repository: AchievementListRepository()), + fetchCategoryListUseCase: .init(repository: CategoryListRepository()) + ) let homeVC = HomeViewController(viewModel: homeVM) homeVC.coordinator = self navigationController.viewControllers = [homeVC] diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 49caddf0..915242f3 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -37,10 +37,10 @@ final class HomeViewController: BaseViewController { viewModel.action(.launch) // TODO: 카테고리 리스트 API를 받았을 때 실행시켜야 함. 지금은 임시로 0.1초 후에 실행 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { - self.layoutView.categoryCollectionView.selectItem(at: [0, 0], animated: false, scrollPosition: .init()) - self.collectionView(self.layoutView.categoryCollectionView.self, didSelectItemAt: IndexPath(item: 0, section: 0)) - }) +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { +// self.layoutView.categoryCollectionView.selectItem(at: [0, 0], animated: false, scrollPosition: .init()) +// self.collectionView(self.layoutView.categoryCollectionView.self, didSelectItemAt: IndexPath(item: 0, section: 0)) +// }) } // MARK: - Methods diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index a7b90c34..1153b3ac 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -26,44 +26,26 @@ final class HomeViewModel { } typealias AchievementDataSource = ListDiffableDataSource - typealias CategoryDataSource = ListDiffableDataSource + typealias CategoryDataSource = ListDiffableDataSource private var categoryDataSource: CategoryDataSource? - + private let fetchCategoryListUseCase: FetchCategoryListUseCase + private var achievementDataSource: AchievementDataSource? private let fetchAchievementListUseCase: FetchAchievementListUseCase - private var categories: [String] = [ - "글자 크기가1", - "다른 문자열입니다.2", - "글자3", - "크기가 다른4", - "문자열5", - "글자 크기가6", - "다른 문자열입니다.7", - "글자8", - "크기가 다른9", - "문자열10", - "글자 크기가11", - "다른 문자열입니다.12", - "글자13", - "크기가 다른14", - "문자열15", - "글자 크기가16", - "다른 문자열입니다.17", - "글자18", - "크기가 다른19", - "문자열20" - ] + private var categories: [CategoryItem] = [] private var achievements: [Achievement] = [] @Published private(set) var categoryState: CategoryState = .initial @Published private(set) var achievementState: AchievementState = .initial init( - fetchAchievementListUseCase: FetchAchievementListUseCase + fetchAchievementListUseCase: FetchAchievementListUseCase, + fetchCategoryListUseCase: FetchCategoryListUseCase ) { self.fetchAchievementListUseCase = fetchAchievementListUseCase + self.fetchCategoryListUseCase = fetchCategoryListUseCase } func action(_ action: HomeViewModelAction) { @@ -86,8 +68,20 @@ final class HomeViewModel { return achievements[index] } + func findCategory(at index: Int) -> CategoryItem { + return categories[index] + } + private func fetchCategories() { - categoryDataSource?.update(data: categories) + Task { + do { + categories = try await fetchCategoryListUseCase.execute() + categoryState = .finish + categoryDataSource?.update(data: categories) + } catch { + categoryState = .error(message: error.localizedDescription) + } + } } private func fetchAchievementList() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift index 7f1e511b..d42cccaf 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift @@ -62,7 +62,10 @@ public final class TabBarCoordinator: Coordinator { // MARK: - Make Child ViewControllers private extension TabBarCoordinator { func makeIndividualTabPage() -> UINavigationController { - let homeVM = HomeViewModel(fetchAchievementListUseCase: .init(repository: AchievementListRepository())) + let homeVM = HomeViewModel( + fetchAchievementListUseCase: .init(repository: AchievementListRepository()), + fetchCategoryListUseCase: .init(repository: CategoryListRepository()) + ) let homeVC = HomeViewController(viewModel: homeVM) homeVC.tabBarItem.image = SymbolImage.individualTabItem From 55e49486418752da31716c41341bf3c2d8f0614b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 14:22:06 +0900 Subject: [PATCH 122/188] =?UTF-8?q?[iOS]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=84=A0=ED=83=9D=ED=95=98=EB=A9=B4=20Hea?= =?UTF-8?q?derView=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Home/Cell/HeaderView.swift | 13 +++--- .../Sources/Presentation/Home/HomeView.swift | 14 ++++++- .../Home/HomeViewController.swift | 42 +++++++++++++++---- .../Presentation/Home/HomeViewModel.swift | 5 ++- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift index d3210654..f5b6e253 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift @@ -6,6 +6,7 @@ // import UIKit +import Domain final class HeaderView: UICollectionViewCell { @@ -24,7 +25,7 @@ final class HeaderView: UICollectionViewCell { private var titleLabel: UILabel = { let label = UILabel() - label.text = "성공" + label.text = "회 성공" label.font = .xlarge return label }() @@ -73,7 +74,7 @@ final class HeaderView: UICollectionViewCell { addSubview(titleLabel) titleLabel.atl .top(equalTo: countLabel.topAnchor) - .left(equalTo: countLabel.rightAnchor, constant: 5) + .left(equalTo: countLabel.rightAnchor) } private func setupDateLabel() { @@ -84,10 +85,10 @@ final class HeaderView: UICollectionViewCell { } // MARK: - Method - func configure(category: String, count: String, date: String) { - categoryLabel.text = category - countLabel.text = count - dateLabel.text = "최근 달성일\n" + date + func configure(category: CategoryItem) { + categoryLabel.text = category.name + countLabel.text = "\(category.continued)" + dateLabel.text = "최근 달성일\n" + "\(category.lastChallenged)" } func showSkeleton() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift index 1d977f51..4d4b276e 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift @@ -7,6 +7,7 @@ import UIKit import Design +import Domain final class HomeView: UIView { @@ -54,7 +55,18 @@ final class HomeView: UIView { setupUI() } - // MARK: - Setup + // MARK: - Methods + func updateAchievementHeader(with category: CategoryItem) { + guard let header = achievementCollectionView.visibleSupplementaryViews( + ofKind: UICollectionView.elementKindSectionHeader + ).first as? HeaderView else { return } + + header.configure(category: category) + } +} + +// MARK: - SetUp +private extension HomeView { private func setupUI() { setupCategoryAddButton() setupCategoryCollectionView() diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 915242f3..ed0eae5f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -35,22 +35,34 @@ final class HomeViewController: BaseViewController { setupCategoryDataSource() viewModel.action(.launch) - - // TODO: 카테고리 리스트 API를 받았을 때 실행시켜야 함. 지금은 임시로 0.1초 후에 실행 -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: { -// self.layoutView.categoryCollectionView.selectItem(at: [0, 0], animated: false, scrollPosition: .init()) -// self.collectionView(self.layoutView.categoryCollectionView.self, didSelectItemAt: IndexPath(item: 0, section: 0)) -// }) } // MARK: - Methods private func bind() { viewModel.$achievementState + .receive(on: DispatchQueue.main) .sink { state in // state 에 따른 뷰 처리 - 스켈레톤 뷰, fetch 에러 뷰 등 Logger.debug(state) } .store(in: &cancellables) + + viewModel.$categoryState + .receive(on: DispatchQueue.main) + .sink { [weak self] categoryState in + guard let self else { return } + switch categoryState { + case .initial: + // TODO: 스켈레톤 + break + case .finish: + // 첫 번째 아이템 선택 + self.selectFirstCategory() + case .error(let message): + Logger.error("Category State Error: \(message)") + } + } + .store(in: &cancellables) } private func addTargets() { @@ -96,7 +108,9 @@ final class HomeViewController: BaseViewController { withReuseIdentifier: HeaderView.identifier, for: indexPath) as? HeaderView - headerView?.configure(category: "다이어트", count: "32회", date: "2023-11-03") +// let category = viewModel.findCategory(at: indexPath.row) +// headerView?.configure(category: category) + return headerView } @@ -118,6 +132,12 @@ final class HomeViewController: BaseViewController { let diffableDataSource = HomeViewModel.CategoryDataSource(dataSource: dataSource) viewModel.setupCategoryDataSource(diffableDataSource) } + + private func selectFirstCategory() { + let firstIndexPath = IndexPath(item: 0, section: 0) + layoutView.categoryCollectionView.selectItem(at: firstIndexPath, animated: false, scrollPosition: .init()) + collectionView(layoutView.categoryCollectionView.self, didSelectItemAt: firstIndexPath) + } } extension HomeViewController: UICollectionViewDelegate { @@ -125,7 +145,8 @@ extension HomeViewController: UICollectionViewDelegate { // 카테고리 셀을 눌렀을 때 if let cell = collectionView.cellForItem(at: indexPath) as? CategoryCollectionViewCell { - categoryCellDidSelected(cell: cell) + Logger.debug("Selected Category: \(indexPath.row)") + categoryCellDidSelected(cell: cell, row: indexPath.row) } else if let cell = collectionView.cellForItem(at: indexPath) as? AchievementCollectionViewCell { // 달성 기록 리스트 셀을 눌렀을 때 // TODO: 상세 정보 화면으로 이동 @@ -133,7 +154,7 @@ extension HomeViewController: UICollectionViewDelegate { } } - private func categoryCellDidSelected(cell: CategoryCollectionViewCell) { + private func categoryCellDidSelected(cell: CategoryCollectionViewCell, row: Int) { // 눌렸을 때 Bounce 적용 // Highlight에만 적용하면 Select에서는 적용이 안 되서 별도로 적용함 UIView.animate(withDuration: 0.08, animations: { @@ -143,6 +164,9 @@ extension HomeViewController: UICollectionViewDelegate { }, completion: { _ in cell.transform = .identity }) + + let category = viewModel.findCategory(at: row) + layoutView.updateAchievementHeader(with: category) } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 1153b3ac..ac29c75a 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -62,6 +62,7 @@ final class HomeViewModel { func setupAchievementDataSource(_ dataSource: AchievementDataSource) { self.achievementDataSource = dataSource + achievementDataSource?.update(data: []) } func findAchievement(at index: Int) -> Achievement { @@ -76,8 +77,8 @@ final class HomeViewModel { Task { do { categories = try await fetchCategoryListUseCase.execute() - categoryState = .finish categoryDataSource?.update(data: categories) + categoryState = .finish } catch { categoryState = .error(message: error.localizedDescription) } @@ -88,8 +89,8 @@ final class HomeViewModel { Task { do { achievements = try await fetchAchievementListUseCase.execute() - achievementState = .finish achievementDataSource?.update(data: achievements) + achievementState = .finish } catch { achievementState = .error(message: error.localizedDescription) } From 80c529cd8cd8126c861b7af9c0b6816e4c6166f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 14:28:39 +0900 Subject: [PATCH 123/188] =?UTF-8?q?[iOS]=20feat:=20YYYY-MM-dd=EB=A1=9C=20?= =?UTF-8?q?=EC=B5=9C=EA=B7=BC=20=EB=8B=AC=EC=84=B1=EC=9D=BC=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moti/Domain/Sources/Domain/Entity/CategoryItem.swift | 6 ++++++ .../Sources/Presentation/Home/Cell/HeaderView.swift | 3 ++- .../Sources/Presentation/Home/HomeViewController.swift | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift index 3025eaa9..e5634c5c 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift @@ -15,6 +15,12 @@ public struct CategoryItem: Hashable { public let continued: Int public let lastChallenged: Date + public var displayLastChallenged: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + return dateFormatter.string(from: lastChallenged) + } + public init( id: Int, name: String, diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift index f5b6e253..ecf739a6 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/Cell/HeaderView.swift @@ -34,6 +34,7 @@ final class HeaderView: UICollectionViewCell { let label = UILabel() label.numberOfLines = 2 label.font = .small + label.textAlignment = .right return label }() @@ -88,7 +89,7 @@ final class HeaderView: UICollectionViewCell { func configure(category: CategoryItem) { categoryLabel.text = category.name countLabel.text = "\(category.continued)" - dateLabel.text = "최근 달성일\n" + "\(category.lastChallenged)" + dateLabel.text = "최근 달성일\n" + "\(category.displayLastChallenged)" } func showSkeleton() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index ed0eae5f..aa9869c5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -143,9 +143,8 @@ final class HomeViewController: BaseViewController { extension HomeViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - // 카테고리 셀을 눌렀을 때 if let cell = collectionView.cellForItem(at: indexPath) as? CategoryCollectionViewCell { - Logger.debug("Selected Category: \(indexPath.row)") + // 카테고리 셀을 눌렀을 때 categoryCellDidSelected(cell: cell, row: indexPath.row) } else if let cell = collectionView.cellForItem(at: indexPath) as? AchievementCollectionViewCell { // 달성 기록 리스트 셀을 눌렀을 때 @@ -166,6 +165,7 @@ extension HomeViewController: UICollectionViewDelegate { }) let category = viewModel.findCategory(at: row) + Logger.debug("Selected Category: \(category.name)") layoutView.updateAchievementHeader(with: category) } From 635cc9d59648d630479964be220bb6e0c0bc699b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 14:54:35 +0900 Subject: [PATCH 124/188] =?UTF-8?q?[iOS]=20feat:=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift index d6f8a3c0..a64fd8b2 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift @@ -21,7 +21,7 @@ struct CategoryDTO: Codable { let lastChallanged: Date? } -extension Domain.CategoryItem { +extension CategoryItem { init(dto: CategoryDTO) { self.init( id: dto.id ?? -1, From f1398d15aff54e4454fe7456c47f019077db86c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 15:33:03 +0900 Subject: [PATCH 125/188] =?UTF-8?q?[iOS]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80=20VM=20action=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/DTO/CategoryListDTO.swift | 6 ++++ .../Data/Network/Endpoint/MotiAPI.swift | 9 +++++- .../Repository/CategoryListRepository.swift | 7 +++++ .../CategoryListRepositoryProtocol.swift | 1 + .../Domain/UseCase/AddCategoryUseCase.swift | 28 ++++++++++++++++++ .../Presentation/Home/HomeViewModel.swift | 29 ++++++++++++++++++- 6 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift index a64fd8b2..19b6c7ee 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift @@ -8,6 +8,12 @@ import Foundation import Domain +struct CategoryResponseDataDTO: ResponseDataDTO { + let success: Bool? + let message: String? + let data: CategoryDTO? +} + struct CategoryListResponseDTO: ResponseDataDTO { let success: Bool? let message: String? diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift index 787d17f7..93047fa6 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift @@ -14,6 +14,7 @@ enum MotiAPI: EndpointProtocol { case autoLogin(requestValue: AutoLoginRequestValue) case fetchAchievementList(requestValue: FetchAchievementListRequestValue?) case fetchCategoryList + case addCategory(requestValue: AddCategoryRequestValue) } extension MotiAPI { @@ -32,6 +33,7 @@ extension MotiAPI { case .autoLogin: return "/auth/refresh" case .fetchAchievementList: return "/achievements" case .fetchCategoryList: return "/categories" + case .addCategory: return "/categories" } } @@ -42,6 +44,7 @@ extension MotiAPI { case .autoLogin: return .post case .fetchAchievementList: return .get case .fetchCategoryList: return .get + case .addCategory: return .post } } @@ -57,6 +60,8 @@ extension MotiAPI { return requestValue case .fetchCategoryList: return nil + case .addCategory: + return nil } } @@ -72,6 +77,8 @@ extension MotiAPI { return nil case .fetchCategoryList: return nil + case .addCategory(let requestValue): + return requestValue } } @@ -81,7 +88,7 @@ extension MotiAPI { switch self { case .version, .login: break - case .autoLogin, .fetchAchievementList, .fetchCategoryList: + default: // TODO: Keychain Storage로 변경 if let accessToken = UserDefaults.standard.string(forKey: "accessToken") { header["Authorization"] = "Bearer \(accessToken)" diff --git a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift index 3616be92..913b987d 100644 --- a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift @@ -51,4 +51,11 @@ public struct CategoryListRepository: CategoryListRepositoryProtocol { return categoryDTO.map { CategoryItem(dto: $0) } } + public func addCategory(requestValue: AddCategoryRequestValue) async throws -> Bool { + let endpoint = MotiAPI.addCategory(requestValue: requestValue) + let responseDTO = try await provider.request(with: endpoint, type: CategoryResponseDataDTO.self) + + guard let isSuccess = responseDTO.success else { throw NetworkError.decode } + return isSuccess + } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift index f37310f4..fafbf0cb 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift @@ -9,4 +9,5 @@ import Foundation public protocol CategoryListRepositoryProtocol { func fetchCategoryList() async throws -> [CategoryItem] + func addCategory(requestValue: AddCategoryRequestValue) async throws -> Bool } diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift new file mode 100644 index 00000000..10f57a5a --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift @@ -0,0 +1,28 @@ +// +// AddCategoryUseCase.swift +// +// +// Created by 유정주 on 11/22/23. +// + +import Foundation + +public struct AddCategoryRequestValue: RequestValue { + public let name: String + + public init(name: String) { + self.name = name + } +} + +public struct AddCategoryUseCase { + private let repository: CategoryListRepositoryProtocol + + public init(repository: CategoryListRepositoryProtocol) { + self.repository = repository + } + + public func execute(requestValue: AddCategoryRequestValue) async throws -> Bool { + return try await repository.addCategory(requestValue: requestValue) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index ac29c75a..c4f1b4e9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -11,6 +11,7 @@ import Domain final class HomeViewModel { enum HomeViewModelAction { case launch + case addCategory(name: String) } enum CategoryState { @@ -19,6 +20,13 @@ final class HomeViewModel { case error(message: String) } + enum AddCategoryState { + case none + case loading + case finish + case error(message: String) + } + enum AchievementState { case initial case finish @@ -30,6 +38,7 @@ final class HomeViewModel { private var categoryDataSource: CategoryDataSource? private let fetchCategoryListUseCase: FetchCategoryListUseCase + private let addCategoryUseCase: AddCategoryUseCase private var achievementDataSource: AchievementDataSource? private let fetchAchievementListUseCase: FetchAchievementListUseCase @@ -38,14 +47,17 @@ final class HomeViewModel { private var achievements: [Achievement] = [] @Published private(set) var categoryState: CategoryState = .initial + @Published private(set) var addCategoryState: AddCategoryState = .none @Published private(set) var achievementState: AchievementState = .initial init( fetchAchievementListUseCase: FetchAchievementListUseCase, - fetchCategoryListUseCase: FetchCategoryListUseCase + fetchCategoryListUseCase: FetchCategoryListUseCase, + addCategoryUseCase: AddCategoryUseCase ) { self.fetchAchievementListUseCase = fetchAchievementListUseCase self.fetchCategoryListUseCase = fetchCategoryListUseCase + self.addCategoryUseCase = addCategoryUseCase } func action(_ action: HomeViewModelAction) { @@ -53,6 +65,8 @@ final class HomeViewModel { case .launch: fetchCategories() fetchAchievementList() + case .addCategory(let name): + addCategory(name: name) } } @@ -85,6 +99,19 @@ final class HomeViewModel { } } + private func addCategory(name: String) { + Task { + addCategoryState = .loading + let requestValue = AddCategoryRequestValue(name: name) + let isSuccess = try? await addCategoryUseCase.execute(requestValue: requestValue) + if let isSuccess { + addCategoryState = .finish + } else { + addCategoryState = .error(message: "카테고리 추가를 실패했습니다.") + } + } + } + private func fetchAchievementList() { Task { do { From 4f1f64efb9a5aec6cb3a329a5ed2b707390d3b15 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Wed, 22 Nov 2023 16:45:06 +0900 Subject: [PATCH 126/188] =?UTF-8?q?[iOS]=20feat:=20detailAchievement=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 더보기(3dot) 버튼을 누르면 이동하도록 테스트 - 홈 뷰에서 썸네일을 누르면 이동하도록 변경 예정 --- .../Presentation/TabBar/TabBarCoordinator.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift index 7f1e511b..f43aa922 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift @@ -105,11 +105,23 @@ private extension TabBarCoordinator { image: SymbolImage.ellipsisCircle, style: .done, target: self, - action: nil + action: #selector(moveTest) ) viewController.navigationItem.rightBarButtonItems = [profileItem, moreItem] } + + @objc private func moveTest() { + let detailAchievementVC = DetailAchievementViewController() + detailAchievementVC.navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil), + UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) + ] + + let navVC = UINavigationController(rootViewController: detailAchievementVC) +// navigationController.pushViewController(detailAchievementVC, animated: true) + navigationController.present(navVC, animated: true) + } } extension TabBarCoordinator: TabBarViewControllerDelegate { From e792be7e2d33d9f869e7085371368fc682c5ee42 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Wed, 22 Nov 2023 16:46:03 +0900 Subject: [PATCH 127/188] =?UTF-8?q?[iOS]=20feat:=20DetailAchievement=20UI?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AchievementView 사용 --- .../Detail/DetailAchievementCoordinator.swift | 37 +++++++++++++++++ .../Detail/DetailAchievementView.swift | 40 +++++++++++++++++++ .../DetailAchievementViewController.swift | 24 +++++++++++ 3 files changed, 101 insertions(+) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift new file mode 100644 index 00000000..0f57616b --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift @@ -0,0 +1,37 @@ +// +// DetailAchievementCoordinator.swift +// +// +// Created by Kihyun Lee on 11/22/23. +// + +import UIKit +import Core + +public final class DetailAchievementCoordinator: Coordinator { + public var parentCoordinator: Coordinator? + public var childCoordinators: [Coordinator] = [] + public var navigationController: UINavigationController + + public init( + _ navigationController: UINavigationController, + _ parentCoordinator: Coordinator? + ) { + self.navigationController = navigationController + self.parentCoordinator = parentCoordinator + } + + public func start() { + let detailAchievementVC = DetailAchievementViewController() + detailAchievementVC.coordinator = self + + detailAchievementVC.navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil), + UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil) + ] + + detailAchievementVC.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) + + navigationController.pushViewController(detailAchievementVC, animated: true) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift new file mode 100644 index 00000000..5cf644e4 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift @@ -0,0 +1,40 @@ +// +// DetailAchievementView.swift +// +// +// Created by Kihyun Lee on 11/22/23. +// + +import UIKit +import Design + +final class DetailAchievementView: UIView { + + // MARK: - Views + let achievementView = AchievementView() + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } +} + +private extension DetailAchievementView { + func setupUI() { + setupAchievementView() + } + + func setupAchievementView() { + addSubview(achievementView) + achievementView.atl + .top(equalTo: safeAreaLayoutGuide.topAnchor) + .bottom(equalTo: bottomAnchor) + .horizontal(equalTo: safeAreaLayoutGuide) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift new file mode 100644 index 00000000..19e22241 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift @@ -0,0 +1,24 @@ +// +// DetailAchievementViewController.swift +// +// +// Created by Kihyun Lee on 11/22/23. +// + +import UIKit +import Design + +final class DetailAchievementViewController: BaseViewController { + weak var coordinator: DetailAchievementCoordinator? + + override func viewDidLoad() { + super.viewDidLoad() + + layoutView.achievementView.update(image: MotiImage.sample1) + layoutView.achievementView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) + } + + @objc private func showPicker() { + layoutView.achievementView.showCategoryPicker() + } +} From c8acbc300aec17051983069d46aa29535b753001 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 16:53:39 +0900 Subject: [PATCH 128/188] =?UTF-8?q?[BE]=20feat:=20round=20->=20achieveCoun?= =?UTF-8?q?t=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/application/achievement.service.spec.ts | 2 +- BE/src/achievement/dto/achievement-detail-response.ts | 2 +- BE/src/achievement/dto/category-info.ts | 4 ++-- BE/src/achievement/entities/achievement.repository.spec.ts | 2 +- BE/src/achievement/entities/achievement.repository.ts | 2 +- BE/src/achievement/index.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/BE/src/achievement/application/achievement.service.spec.ts b/BE/src/achievement/application/achievement.service.spec.ts index 754d5cea..fa868f73 100644 --- a/BE/src/achievement/application/achievement.service.spec.ts +++ b/BE/src/achievement/application/achievement.service.spec.ts @@ -112,7 +112,7 @@ describe('AchievementService Test', () => { expect(detail.imageUrl).toBeDefined(); expect(detail.category.id).toEqual(category.id); expect(detail.category.name).toEqual(category.name); - expect(detail.category.round).toEqual(8); + expect(detail.category.achieveCount).toEqual(8); }); test('자신이 소유하지 않은 달성 기록 정보를 조회하면 NoSuchAchievementException을 던진다.', async () => { diff --git a/BE/src/achievement/dto/achievement-detail-response.ts b/BE/src/achievement/dto/achievement-detail-response.ts index bc583760..9469d31b 100644 --- a/BE/src/achievement/dto/achievement-detail-response.ts +++ b/BE/src/achievement/dto/achievement-detail-response.ts @@ -25,7 +25,7 @@ export class AchievementDetailResponse { this.category = new CategoryInfo( achievementDetail.categoryId, achievementDetail.categoryName, - Number(achievementDetail.round), + Number(achievementDetail.achieveCount), ); } } diff --git a/BE/src/achievement/dto/category-info.ts b/BE/src/achievement/dto/category-info.ts index 630512fa..58144d97 100644 --- a/BE/src/achievement/dto/category-info.ts +++ b/BE/src/achievement/dto/category-info.ts @@ -6,10 +6,10 @@ export class CategoryInfo { @ApiProperty({ description: 'name' }) name: string; @ApiProperty({ description: '회차 수' }) - round: number; + achieveCount: number; constructor(id: number, name: string, round: number) { this.id = id; this.name = name; - this.round = round; + this.achieveCount = round; } } diff --git a/BE/src/achievement/entities/achievement.repository.spec.ts b/BE/src/achievement/entities/achievement.repository.spec.ts index f2eb5c6c..dcce1599 100644 --- a/BE/src/achievement/entities/achievement.repository.spec.ts +++ b/BE/src/achievement/entities/achievement.repository.spec.ts @@ -197,7 +197,7 @@ describe('AchievementRepository test', () => { expect(achievementDetail.imageUrl).toBeDefined(); expect(achievementDetail.category.id).toEqual(category.id); expect(achievementDetail.category.name).toEqual(category.name); - expect(achievementDetail.category.round).toEqual(6); + expect(achievementDetail.category.achieveCount).toEqual(6); }); }); diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts index 09576b6e..f9261f11 100644 --- a/BE/src/achievement/entities/achievement.repository.ts +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -51,7 +51,7 @@ export class AchievementRepository extends TransactionalRepository Date: Wed, 22 Nov 2023 17:12:39 +0900 Subject: [PATCH 129/188] =?UTF-8?q?[iOS]=20feat:=20moveTest=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/TabBar/TabBarCoordinator.swift | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift index f43aa922..2f53948e 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift @@ -105,23 +105,12 @@ private extension TabBarCoordinator { image: SymbolImage.ellipsisCircle, style: .done, target: self, - action: #selector(moveTest) + action: nil ) viewController.navigationItem.rightBarButtonItems = [profileItem, moreItem] } - @objc private func moveTest() { - let detailAchievementVC = DetailAchievementViewController() - detailAchievementVC.navigationItem.rightBarButtonItems = [ - UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil), - UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) - ] - - let navVC = UINavigationController(rootViewController: detailAchievementVC) -// navigationController.pushViewController(detailAchievementVC, animated: true) - navigationController.present(navVC, animated: true) - } } extension TabBarCoordinator: TabBarViewControllerDelegate { From f926886e8507a215ee8fa1f52f72e9e938b67af4 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 17:36:18 +0900 Subject: [PATCH 130/188] =?UTF-8?q?[BE]=20feat:=20CategoryResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - id 프로퍼티를 추가함 --- BE/src/category/dto/category-metadata.ts | 2 +- BE/src/category/dto/category.response.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/BE/src/category/dto/category-metadata.ts b/BE/src/category/dto/category-metadata.ts index 93e97b98..3fcc656e 100644 --- a/BE/src/category/dto/category-metadata.ts +++ b/BE/src/category/dto/category-metadata.ts @@ -9,7 +9,7 @@ export class CategoryMetaData { constructor(categoryMetaData: ICategoryMetaData) { this.categoryId = categoryMetaData.categoryId; this.categoryName = categoryMetaData.categoryName; - this.insertedAt = new Date(categoryMetaData.insertedAt); + this.insertedAt = categoryMetaData.insertedAt ? new Date() : null; this.achievementCount = isNaN(Number(categoryMetaData.achievementCount)) ? 0 : parseInt(categoryMetaData.achievementCount); diff --git a/BE/src/category/dto/category.response.ts b/BE/src/category/dto/category.response.ts index 1fd861a9..ddc1ecdd 100644 --- a/BE/src/category/dto/category.response.ts +++ b/BE/src/category/dto/category.response.ts @@ -2,14 +2,18 @@ import { ApiProperty } from '@nestjs/swagger'; import { Category } from '../domain/category.domain'; export class CategoryResponse { + @ApiProperty({ description: '카테고리 아이디' }) + id: number; + @ApiProperty({ description: '카테고리 이름' }) name: string; - constructor(name: string) { + constructor(id: number, name: string) { + this.id = id; this.name = name; } static from(category: Category) { - return new CategoryResponse(category.name); + return new CategoryResponse(category.id, category.name); } } From f6145400c0051477d4001ad3eebd40361c82c52c Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 17:40:07 +0900 Subject: [PATCH 131/188] =?UTF-8?q?[BE]=20fix:=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=98=20=EB=AA=BB=20=EB=90=98=EC=97=88=EC=9D=84?= =?UTF-8?q?=20=EB=95=8C=20=EB=B0=9C=EC=83=9D=EB=90=98=EB=8A=94=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/decorator/athenticated-user.decorator.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/BE/src/auth/decorator/athenticated-user.decorator.ts b/BE/src/auth/decorator/athenticated-user.decorator.ts index 4ce27c08..a477c10c 100644 --- a/BE/src/auth/decorator/athenticated-user.decorator.ts +++ b/BE/src/auth/decorator/athenticated-user.decorator.ts @@ -1,20 +1,11 @@ -import { - createParamDecorator, - ExecutionContext, - InternalServerErrorException, -} from '@nestjs/common'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { InvalidTokenException } from '../exception/invalid-token.exception'; export const AuthenticatedUser = createParamDecorator( (data, context: ExecutionContext) => { const req = context.switchToHttp().getRequest(); - const user = req.user; - - if (!user) { - throw new InternalServerErrorException( - 'request user 프로퍼티가 없습니다.', - ); - } + if (!user) throw new InvalidTokenException(); return user; }, From 10ebf137b9bc5b904ad279c8bda2333a57f4f5e1 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Wed, 22 Nov 2023 17:48:05 +0900 Subject: [PATCH 132/188] =?UTF-8?q?[iOS]=20feat:=20=ED=99=88=20=EC=BD=94?= =?UTF-8?q?=EB=94=94=EB=84=A4=EC=9D=B4=ED=84=B0=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/TabBar/TabBarCoordinator.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift index 2f53948e..ada08f93 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift @@ -69,7 +69,12 @@ private extension TabBarCoordinator { homeVC.tabBarItem.title = TabItemType.individual.title setupIndividualHomeNavigationBar(viewController: homeVC) - return UINavigationController(rootViewController: homeVC) + let navVC = UINavigationController(rootViewController: homeVC) + + let homeCoordinator = HomeCoordinator(navVC, self) + homeVC.coordinator = homeCoordinator + childCoordinators.append(homeCoordinator) + return navVC } func makeGroupTabPage() -> UINavigationController { From dd8bce5b4a46e6fe0cedb5860a97bf9eb0ddd380 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Wed, 22 Nov 2023 18:08:35 +0900 Subject: [PATCH 133/188] =?UTF-8?q?[iOS]=20feat:=20present=20->=20push=20?= =?UTF-8?q?=EB=A1=9C=20=EB=94=94=ED=85=8C=EC=9D=BC=20=EB=B7=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Home/HomeCoordinator.swift | 10 ++++++++++ .../Sources/Presentation/Home/HomeViewController.swift | 1 + 2 files changed, 11 insertions(+) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift index 7b790ba7..b2d4f21a 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift @@ -31,4 +31,14 @@ public final class HomeCoordinator: Coordinator { homeVC.coordinator = self navigationController.viewControllers = [homeVC] } + + public func moveToDetailAchievementViewController() { + let detailAchievementVC = DetailAchievementViewController() + detailAchievementVC.navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil), + UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) + ] + + navigationController.pushViewController(detailAchievementVC, animated: true) + } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index aa9869c5..0e5632fc 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -149,6 +149,7 @@ extension HomeViewController: UICollectionViewDelegate { } else if let cell = collectionView.cellForItem(at: indexPath) as? AchievementCollectionViewCell { // 달성 기록 리스트 셀을 눌렀을 때 // TODO: 상세 정보 화면으로 이동 + coordinator?.moveToDetailAchievementViewController() Logger.debug("clicked: \(viewModel.findAchievement(at: indexPath.row).title)") } } From fb0168bb2cd38fcc4628bdf0c8429c54bb60766b Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Wed, 22 Nov 2023 18:22:27 +0900 Subject: [PATCH 134/188] =?UTF-8?q?[iOS]=20feat:=20=ED=99=88=20=EC=BD=94?= =?UTF-8?q?=EB=94=94=EB=84=A4=EC=9D=B4=ED=84=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=BD=94=EB=94=94=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=83=9D=EC=84=B1=20=ED=9B=84=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Detail/DetailAchievementCoordinator.swift | 6 ++---- .../Sources/Presentation/Home/HomeCoordinator.swift | 10 +++------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift index 0f57616b..887dbc28 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift @@ -26,12 +26,10 @@ public final class DetailAchievementCoordinator: Coordinator { detailAchievementVC.coordinator = self detailAchievementVC.navigationItem.rightBarButtonItems = [ - UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil), - UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil) + UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil), + UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) ] - detailAchievementVC.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) - navigationController.pushViewController(detailAchievementVC, animated: true) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift index b2d4f21a..b6e36ce9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift @@ -33,12 +33,8 @@ public final class HomeCoordinator: Coordinator { } public func moveToDetailAchievementViewController() { - let detailAchievementVC = DetailAchievementViewController() - detailAchievementVC.navigationItem.rightBarButtonItems = [ - UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil), - UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) - ] - - navigationController.pushViewController(detailAchievementVC, animated: true) + let detailAchievementCoordinator = DetailAchievementCoordinator(navigationController, self) + childCoordinators.append(detailAchievementCoordinator) + detailAchievementCoordinator.start() } } From 3161a8efa267307cb51dc08a84e9a13885bcc504 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Wed, 22 Nov 2023 18:25:31 +0900 Subject: [PATCH 135/188] =?UTF-8?q?[iOS]=20feat:=20achievementView=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EB=AA=A8=EB=93=9C=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Detail/DetailAchievementViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift index 19e22241..ac9004e3 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift @@ -14,6 +14,7 @@ final class DetailAchievementViewController: BaseViewController Date: Wed, 22 Nov 2023 18:32:48 +0900 Subject: [PATCH 136/188] =?UTF-8?q?[iOS]=20feat:=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/DTO/CategoryListDTO.swift | 6 +- .../Data/Network/Provider/Provider.swift | 6 +- .../Repository/CategoryListRepository.swift | 67 ++++++++++++------- .../CategoryListRepositoryTests.swift | 23 ++++++- .../Mock/MockCategoryListRepository.swift | 56 +++++++++++----- .../Sources/Domain/Entity/CategoryItem.swift | 26 +++++-- .../CategoryListRepositoryProtocol.swift | 2 +- .../Domain/UseCase/AddCategoryUseCase.swift | 2 +- .../Presentation/Common/AlertFactory.swift | 25 +++++-- .../Presentation/Home/HomeCoordinator.swift | 3 +- .../Home/HomeViewController.swift | 31 +++++++-- .../Presentation/Home/HomeViewModel.swift | 19 ++++-- .../TabBar/TabBarCoordinator.swift | 3 +- 13 files changed, 198 insertions(+), 71 deletions(-) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift index 19b6c7ee..882ef762 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/CategoryListDTO.swift @@ -24,7 +24,7 @@ struct CategoryDTO: Codable { let id: Int? let name: String? let continued: Int? - let lastChallanged: Date? + let lastChallenged: Date? } extension CategoryItem { @@ -32,8 +32,8 @@ extension CategoryItem { self.init( id: dto.id ?? -1, name: dto.name ?? "", - continued: dto.continued ?? -1, - lastChallenged: dto.lastChallanged ?? .now + continued: dto.continued ?? 0, + lastChallenged: dto.lastChallenged ) } } diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift b/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift index 9a1544c8..b5b12ef4 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift @@ -19,7 +19,11 @@ public struct Provider: ProviderProtocol { encoder.outputFormatting = .prettyPrinted return encoder }() - private let decoder = JSONDecoder() + private let decoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() public init(session: URLSession = URLSession.shared) { self.session = session diff --git a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift index 913b987d..07e819c0 100644 --- a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift @@ -21,41 +21,62 @@ public struct CategoryListRepository: CategoryListRepositoryProtocol { // // guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } // return categoryDTO.map { CategoryItem(dto: $0) } +// } +// +// public func addCategory(requestValue: AddCategoryRequestValue) async throws -> CategoryItem { +// let endpoint = MotiAPI.addCategory(requestValue: requestValue) +// let responseDTO = try await provider.request(with: endpoint, type: CategoryResponseDataDTO.self) +// +// guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } +// return CategoryItem(dto: categoryDTO) // } - private let json = """ + private let fetchCategoryListJson = """ { "success": true, - "data": [ - { - "id": 0, - "name": "전체", - "continued": 100, - "lastChallenged": "2023-11-08T10:20:10.0202" - }, - { - "id": 1000, - "name": "다이어트", - "continued": 32, - "lastChallenged": "2023-11-08T10:20:10.0202" - } - ] + "data": [ + { + "id": 0, + "name": "전체", + "continued": 150, + "lastChallenged": "2011-04-10T20:09:31Z" + }, + { + "id": 1000, + "name": "다이어트", + "continued": 10, + "lastChallenged": "2017-04-10T20:09:31Z" + } + ] + } + """ + + private let addCategoryJson = """ + { + "success": true, + "data": { + "name": "추가된 카테고리 이름", + "id": 10 + } } """ public func fetchCategoryList() async throws -> [CategoryItem] { - guard let testData = json.data(using: .utf8) else { return [] } - let responseDTO = try JSONDecoder().decode(CategoryListResponseDTO.self, from: testData) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + guard let testData = fetchCategoryListJson.data(using: .utf8) else { return [] } + let responseDTO = try decoder.decode(CategoryListResponseDTO.self, from: testData) guard let categoryDTO = responseDTO.data else { return [] } return categoryDTO.map { CategoryItem(dto: $0) } } - public func addCategory(requestValue: AddCategoryRequestValue) async throws -> Bool { - let endpoint = MotiAPI.addCategory(requestValue: requestValue) - let responseDTO = try await provider.request(with: endpoint, type: CategoryResponseDataDTO.self) - - guard let isSuccess = responseDTO.success else { throw NetworkError.decode } - return isSuccess + public func addCategory(requestValue: AddCategoryRequestValue) async throws -> CategoryItem { + guard let testData = addCategoryJson.data(using: .utf8) else { throw NetworkError.decode } + let responseDTO = try JSONDecoder().decode(CategoryResponseDataDTO.self, from: testData) + + guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } + return CategoryItem(dto: categoryDTO) } } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift index 8c2f7d7e..7eda6686 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/CategoryListRepositoryTests.swift @@ -1,11 +1,12 @@ // -// MockCategoryListRepository.swift -// +// CategoryListRepositoryTests.swift +// // // Created by 유정주 on 11/22/23. // import XCTest +@testable import Domain final class CategoryListRepositoryTests: XCTestCase { @@ -30,5 +31,21 @@ final class CategoryListRepositoryTests: XCTestCase { wait(for: [expectation], timeout: 3) } - + + func test_형식에_맞는_json을_디코딩하면_추가된_카테고리_반환() throws { + let source = CategoryItem(id: 10, name: "테스트 카테고리") + + let repository = MockCategoryListRepository() + let expectation = XCTestExpectation(description: "test_형식에_맞는_json을_디코딩하면_추가된_카테고리_반환") + + Task { + let result = try await repository.addCategory(requestValue: .init(name: "테스트 카테고리")) + XCTAssertEqual(result.id, source.id) + XCTAssertEqual(result.name, source.name) + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3) + } } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift index 0145a3ec..1645dd27 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockCategoryListRepository.swift @@ -10,31 +10,51 @@ import Foundation @testable import Data struct MockCategoryListRepository: CategoryListRepositoryProtocol { - private let json = """ + private let fetchCategoryListJson = """ { "success": true, - "data": [ - { - "id": 0, - "name": "전체", - "continued": 100, - "lastChallenged": "2023-11-08T10:20:10.0202" - }, - { - "id": 1000, - "name": "다이어트", - "continued": 32, - "lastChallenged": "2023-11-08T10:20:10.0202" - } - ] + "data": [ + { + "id": 0, + "name": "전체", + "continued": 150, + "lastChallenged": "2011-04-10T20:09:31Z" + }, + { + "id": 1000, + "name": "다이어트", + "continued": 10, + "lastChallenged": "2011-04-10T20:09:31Z" + } + ] + } + """ + + private let addCategoryJson = """ + { + "success": true, + "data": { + "name": "테스트 카테고리", + "id": 10 + } } """ public func fetchCategoryList() async throws -> [CategoryItem] { - guard let testData = json.data(using: .utf8) else { return [] } - let responseDTO = try JSONDecoder().decode(CategoryListResponseDTO.self, from: testData) - + guard let testData = fetchCategoryListJson.data(using: .utf8) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let responseDTO = try decoder.decode(CategoryListResponseDTO.self, from: testData) + guard let categoryDTO = responseDTO.data else { return [] } return categoryDTO.map { CategoryItem(dto: $0) } } + + public func addCategory(requestValue: AddCategoryRequestValue) async throws -> CategoryItem { + guard let testData = addCategoryJson.data(using: .utf8) else { throw NetworkError.decode } + let responseDTO = try JSONDecoder().decode(CategoryResponseDataDTO.self, from: testData) + + guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } + return CategoryItem(dto: categoryDTO) + } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift index e5634c5c..c7121a4d 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/CategoryItem.swift @@ -13,23 +13,41 @@ public struct CategoryItem: Hashable { public let id: Int public let name: String public let continued: Int - public let lastChallenged: Date + public let lastChallenged: Date? - public var displayLastChallenged: String { + private let dateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" - return dateFormatter.string(from: lastChallenged) + return dateFormatter + }() + + public var displayLastChallenged: String { + if let lastChallenged { + return dateFormatter.string(from: lastChallenged) + } else { + return "없음" + } } public init( id: Int, name: String, continued: Int, - lastChallenged: Date + lastChallenged: Date? ) { self.id = id self.name = name self.continued = continued self.lastChallenged = lastChallenged } + + public init( + id: Int, + name: String + ) { + self.id = id + self.name = name + self.continued = 0 + self.lastChallenged = nil + } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift index fafbf0cb..5fcd646e 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/CategoryListRepositoryProtocol.swift @@ -9,5 +9,5 @@ import Foundation public protocol CategoryListRepositoryProtocol { func fetchCategoryList() async throws -> [CategoryItem] - func addCategory(requestValue: AddCategoryRequestValue) async throws -> Bool + func addCategory(requestValue: AddCategoryRequestValue) async throws -> CategoryItem } diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift index 10f57a5a..bd5d0f1e 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/AddCategoryUseCase.swift @@ -22,7 +22,7 @@ public struct AddCategoryUseCase { self.repository = repository } - public func execute(requestValue: AddCategoryRequestValue) async throws -> Bool { + public func execute(requestValue: AddCategoryRequestValue) async throws -> CategoryItem { return try await repository.addCategory(requestValue: requestValue) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift index 0374b209..b3172d3b 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AlertFactory.swift @@ -10,12 +10,13 @@ import UIKit public enum AlertFactory { public static func makeNormalAlert( title: String? = nil, - okTitle: String? = "OK", - okAction: @escaping () -> Void + message: String? = nil, + okTitle: String? = "확인", + okAction: (() -> Void)? = nil ) -> UIAlertController { - let alertVC = UIAlertController(title: title, message: nil, preferredStyle: .alert) + let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert) let okAlert = UIAlertAction(title: okTitle, style: .default) { _ in - okAction() + okAction?() } let cancelAlert = UIAlertAction(title: "취소", style: .cancel) @@ -24,6 +25,21 @@ public enum AlertFactory { return alertVC } + public static func makeOneButtonAlert( + title: String? = nil, + message: String? = nil, + okTitle: String? = "확인", + okAction: (() -> Void)? = nil + ) -> UIAlertController { + let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert) + let okAlert = UIAlertAction(title: okTitle, style: .default) { _ in + okAction?() + } + + alertVC.addAction(okAlert) + return alertVC + } + public static func makeTextFieldAlert( title: String? = nil, okTitle: String? = "OK", @@ -38,6 +54,7 @@ public enum AlertFactory { alertVC.addAction(cancelAlert) alertVC.addAction(okAlert) + // 오토레이아웃 warning이 발생하지만, 애플 에러이므로 무시해도 됨 alertVC.addTextField { myTextField in myTextField.placeholder = placeholder } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift index 7b790ba7..19cce1e9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift @@ -25,7 +25,8 @@ public final class HomeCoordinator: Coordinator { public func start() { let homeVM = HomeViewModel( fetchAchievementListUseCase: .init(repository: AchievementListRepository()), - fetchCategoryListUseCase: .init(repository: CategoryListRepository()) + fetchCategoryListUseCase: .init(repository: CategoryListRepository()), + addCategoryUseCase: .init(repository: CategoryListRepository()) ) let homeVC = HomeViewController(viewModel: homeVM) homeVC.coordinator = self diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index aa9869c5..1905767c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -63,6 +63,24 @@ final class HomeViewController: BaseViewController { } } .store(in: &cancellables) + + viewModel.$addCategoryState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + switch state { + case .none: break + case .loading: + layoutView.catergoryAddButton.isEnabled = false + break + case .finish: + layoutView.catergoryAddButton.isEnabled = true + case .error(let message): + layoutView.catergoryAddButton.isEnabled = true + showErrorAlert(message: message) + } + } + .store(in: &cancellables) } private func addTargets() { @@ -74,13 +92,19 @@ final class HomeViewController: BaseViewController { title: "추가할 카테고리 이름을 입력하세요.", okTitle: "생성", placeholder: "카테고리 이름은 최대 10글자입니다." - ) { text in - Logger.debug(text) + ) { [weak self] text in + guard let self, let text else { return } + viewModel.action(.addCategory(name: text)) } present(alertVC, animated: true) } + private func showErrorAlert(message: String) { + let alertVC = AlertFactory.makeOneButtonAlert(title: "에러", message: message) + present(alertVC, animated: true) + } + // MARK: - Setup private func setupAchievementDataSource() { layoutView.achievementCollectionView.delegate = self @@ -108,9 +132,6 @@ final class HomeViewController: BaseViewController { withReuseIdentifier: HeaderView.identifier, for: indexPath) as? HeaderView -// let category = viewModel.findCategory(at: indexPath.row) -// headerView?.configure(category: category) - return headerView } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index c4f1b4e9..f429ded5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -43,8 +43,16 @@ final class HomeViewModel { private var achievementDataSource: AchievementDataSource? private let fetchAchievementListUseCase: FetchAchievementListUseCase - private var categories: [CategoryItem] = [] - private var achievements: [Achievement] = [] + private var categories: [CategoryItem] = [] { + didSet { + categoryDataSource?.update(data: categories) + } + } + private var achievements: [Achievement] = [] { + didSet { + achievementDataSource?.update(data: achievements) + } + } @Published private(set) var categoryState: CategoryState = .initial @Published private(set) var addCategoryState: AddCategoryState = .none @@ -91,7 +99,6 @@ final class HomeViewModel { Task { do { categories = try await fetchCategoryListUseCase.execute() - categoryDataSource?.update(data: categories) categoryState = .finish } catch { categoryState = .error(message: error.localizedDescription) @@ -103,9 +110,10 @@ final class HomeViewModel { Task { addCategoryState = .loading let requestValue = AddCategoryRequestValue(name: name) - let isSuccess = try? await addCategoryUseCase.execute(requestValue: requestValue) - if let isSuccess { + let category = try? await addCategoryUseCase.execute(requestValue: requestValue) + if let category { addCategoryState = .finish + categories.append(category) } else { addCategoryState = .error(message: "카테고리 추가를 실패했습니다.") } @@ -116,7 +124,6 @@ final class HomeViewModel { Task { do { achievements = try await fetchAchievementListUseCase.execute() - achievementDataSource?.update(data: achievements) achievementState = .finish } catch { achievementState = .error(message: error.localizedDescription) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift index d42cccaf..e2726a42 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarCoordinator.swift @@ -64,7 +64,8 @@ private extension TabBarCoordinator { func makeIndividualTabPage() -> UINavigationController { let homeVM = HomeViewModel( fetchAchievementListUseCase: .init(repository: AchievementListRepository()), - fetchCategoryListUseCase: .init(repository: CategoryListRepository()) + fetchCategoryListUseCase: .init(repository: CategoryListRepository()), + addCategoryUseCase: .init(repository: CategoryListRepository()) ) let homeVC = HomeViewController(viewModel: homeVM) From fdcdfcf6eb3aa5e7279d6c73aa72f7021f49daa3 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 18:43:26 +0900 Subject: [PATCH 137/188] =?UTF-8?q?[BE]=20fix:=20categoryResponse=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/category/dto/category-list-regacy.response.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BE/src/category/dto/category-list-regacy.response.spec.ts b/BE/src/category/dto/category-list-regacy.response.spec.ts index 655d09a0..b0bb76e9 100644 --- a/BE/src/category/dto/category-list-regacy.response.spec.ts +++ b/BE/src/category/dto/category-list-regacy.response.spec.ts @@ -47,7 +47,7 @@ describe('CategoryListLegacyResponse', () => { }); describe('생성된 카테고리가 있을 때 응답이 가능하다.', () => { - it('', () => { + it('다수의 카테고리에 대해 응답이 가능하다.', () => { // given const categoryMetaData: CategoryMetaData[] = []; categoryMetaData.push( From b8a958a20c4d1b74cdf76a31d6cb84ff2add2783 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 18:43:52 +0900 Subject: [PATCH 138/188] =?UTF-8?q?[BE]=20fix:=20CategoryMetaData=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/category/dto/category-metadata.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BE/src/category/dto/category-metadata.ts b/BE/src/category/dto/category-metadata.ts index 3fcc656e..530dbcc2 100644 --- a/BE/src/category/dto/category-metadata.ts +++ b/BE/src/category/dto/category-metadata.ts @@ -9,7 +9,9 @@ export class CategoryMetaData { constructor(categoryMetaData: ICategoryMetaData) { this.categoryId = categoryMetaData.categoryId; this.categoryName = categoryMetaData.categoryName; - this.insertedAt = categoryMetaData.insertedAt ? new Date() : null; + this.insertedAt = categoryMetaData.insertedAt + ? new Date(categoryMetaData.insertedAt) + : null; this.achievementCount = isNaN(Number(categoryMetaData.achievementCount)) ? 0 : parseInt(categoryMetaData.achievementCount); From c8c18d1d805d2116faa7cde33c67fa746684a88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 19:16:34 +0900 Subject: [PATCH 139/188] =?UTF-8?q?[iOS]=20feat:=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/CategoryListRepository.swift | 63 +++---------------- 1 file changed, 7 insertions(+), 56 deletions(-) diff --git a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift index 07e819c0..05ff98f6 100644 --- a/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/CategoryListRepository.swift @@ -15,67 +15,18 @@ public struct CategoryListRepository: CategoryListRepositoryProtocol { self.provider = provider } -// public func fetchCategoryList() async throws -> [CategoryItem] { -// let endpoint = MotiAPI.fetchCategoryList -// let responseDTO = try await provider.request(with: endpoint, type: CategoryListResponseDTO.self) -// -// guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } -// return categoryDTO.map { CategoryItem(dto: $0) } -// } -// -// public func addCategory(requestValue: AddCategoryRequestValue) async throws -> CategoryItem { -// let endpoint = MotiAPI.addCategory(requestValue: requestValue) -// let responseDTO = try await provider.request(with: endpoint, type: CategoryResponseDataDTO.self) -// -// guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } -// return CategoryItem(dto: categoryDTO) -// } - - private let fetchCategoryListJson = """ - { - "success": true, - "data": [ - { - "id": 0, - "name": "전체", - "continued": 150, - "lastChallenged": "2011-04-10T20:09:31Z" - }, - { - "id": 1000, - "name": "다이어트", - "continued": 10, - "lastChallenged": "2017-04-10T20:09:31Z" - } - ] - } - """ - - private let addCategoryJson = """ - { - "success": true, - "data": { - "name": "추가된 카테고리 이름", - "id": 10 - } - } - """ - public func fetchCategoryList() async throws -> [CategoryItem] { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 + let endpoint = MotiAPI.fetchCategoryList + let responseDTO = try await provider.request(with: endpoint, type: CategoryListResponseDTO.self) - guard let testData = fetchCategoryListJson.data(using: .utf8) else { return [] } - let responseDTO = try decoder.decode(CategoryListResponseDTO.self, from: testData) - - guard let categoryDTO = responseDTO.data else { return [] } + guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } return categoryDTO.map { CategoryItem(dto: $0) } } - + public func addCategory(requestValue: AddCategoryRequestValue) async throws -> CategoryItem { - guard let testData = addCategoryJson.data(using: .utf8) else { throw NetworkError.decode } - let responseDTO = try JSONDecoder().decode(CategoryResponseDataDTO.self, from: testData) - + let endpoint = MotiAPI.addCategory(requestValue: requestValue) + let responseDTO = try await provider.request(with: endpoint, type: CategoryResponseDataDTO.self) + guard let categoryDTO = responseDTO.data else { throw NetworkError.decode } return CategoryItem(dto: categoryDTO) } From e30f9982f11469eb116fd59c1fbadfe88c3923ca Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 19:33:44 +0900 Subject: [PATCH 140/188] =?UTF-8?q?[BE]=20feat:=20dateFormat=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 값이 없을 때 undefined이 아닌 null을 반환하도록 적용 --- BE/src/common/utils/date-formatter.spec.ts | 23 ++++++++++++++++++++++ BE/src/common/utils/date-formatter.ts | 3 +++ 2 files changed, 26 insertions(+) create mode 100644 BE/src/common/utils/date-formatter.spec.ts create mode 100644 BE/src/common/utils/date-formatter.ts diff --git a/BE/src/common/utils/date-formatter.spec.ts b/BE/src/common/utils/date-formatter.spec.ts new file mode 100644 index 00000000..a601e402 --- /dev/null +++ b/BE/src/common/utils/date-formatter.spec.ts @@ -0,0 +1,23 @@ +import { dateFormat } from './date-formatter'; + +describe('DateFormatter', () => { + it('날짜 포맷이 적용되어야 한다.', () => { + // given + const date = new Date('2021-01-01T00:00:00.000Z'); + + // when + const formattedDate = dateFormat(date); + + // then + expect(formattedDate).toBe('2021-01-01T00:00:00Z'); + }); + + it('날짜 포맷이 적용되어야 한다.', () => { + // given + // when + const formattedDate = dateFormat(undefined); + + // then + expect(formattedDate).toBeNull(); + }); +}); diff --git a/BE/src/common/utils/date-formatter.ts b/BE/src/common/utils/date-formatter.ts new file mode 100644 index 00000000..0d675112 --- /dev/null +++ b/BE/src/common/utils/date-formatter.ts @@ -0,0 +1,3 @@ +export function dateFormat(date: Date): string { + return date?.toISOString().slice(0, -5).concat('Z') || null; +} From 3820c9421eb5bfc4f16e81941ba7059816f6ef9d Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 19:34:12 +0900 Subject: [PATCH 141/188] =?UTF-8?q?[BE]=20feat:=20=EA=B8=B0=EC=A1=B4=20API?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=82=A0=EC=A7=9C=20=ED=8F=AC=EB=A9=A7=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/category/dto/category-list-element.response.ts | 5 +++-- BE/src/category/dto/category-list-legacy.response.ts | 3 ++- BE/src/category/dto/category-list-regacy.response.spec.ts | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/BE/src/category/dto/category-list-element.response.ts b/BE/src/category/dto/category-list-element.response.ts index 102544e8..f02de47c 100644 --- a/BE/src/category/dto/category-list-element.response.ts +++ b/BE/src/category/dto/category-list-element.response.ts @@ -1,4 +1,5 @@ import { CategoryMetaData } from './category-metadata'; +import { dateFormat } from '../../common/utils/date-formatter'; export class CategoryListElementResponse { id: number; @@ -10,7 +11,7 @@ export class CategoryListElementResponse { this.id = category.categoryId; this.name = category.categoryName; this.continued = category.achievementCount; - this.lastChallenged = category.insertedAt?.toISOString() || null; + this.lastChallenged = dateFormat(category.insertedAt); } static totalCategoryElement() { @@ -34,7 +35,7 @@ export class CategoryListElementResponse { !totalItem.lastChallenged || new Date(totalItem.lastChallenged) < category.insertedAt ) - totalItem.lastChallenged = category.insertedAt.toISOString(); + totalItem.lastChallenged = dateFormat(category.insertedAt); totalItem.continued += category.achievementCount; categories.push(new CategoryListElementResponse(category)); diff --git a/BE/src/category/dto/category-list-legacy.response.ts b/BE/src/category/dto/category-list-legacy.response.ts index 344478cd..6207246d 100644 --- a/BE/src/category/dto/category-list-legacy.response.ts +++ b/BE/src/category/dto/category-list-legacy.response.ts @@ -1,6 +1,7 @@ import { CategoryMetaData } from './category-metadata'; import { ApiProperty } from '@nestjs/swagger'; import { CategoryListElementResponse } from './category-list-element.response'; +import { dateFormat } from '../../common/utils/date-formatter'; interface CategoryLegacyList { [key: string]: CategoryListElementResponse; @@ -24,7 +25,7 @@ export class CategoryListLegacyResponse { !totalItem.lastChallenged || new Date(totalItem.lastChallenged) < category.insertedAt ) - totalItem.lastChallenged = category.insertedAt.toISOString(); + totalItem.lastChallenged = dateFormat(category.insertedAt); totalItem.continued += category.achievementCount; this.categories[category.categoryName] = new CategoryListElementResponse( diff --git a/BE/src/category/dto/category-list-regacy.response.spec.ts b/BE/src/category/dto/category-list-regacy.response.spec.ts index b0bb76e9..70f2d0bf 100644 --- a/BE/src/category/dto/category-list-regacy.response.spec.ts +++ b/BE/src/category/dto/category-list-regacy.response.spec.ts @@ -86,19 +86,19 @@ describe('CategoryListLegacyResponse', () => { id: 0, name: '전체', continued: 3, - lastChallenged: '2021-01-02T00:00:00.000Z', + lastChallenged: '2021-01-02T00:00:00Z', }); expect(categoryListResponse.categories['카테고리1']).toEqual({ id: 1, name: '카테고리1', continued: 1, - lastChallenged: '2021-01-01T00:00:00.000Z', + lastChallenged: '2021-01-01T00:00:00Z', }); expect(categoryListResponse.categories['카테고리2']).toEqual({ id: 2, name: '카테고리2', continued: 2, - lastChallenged: '2021-01-02T00:00:00.000Z', + lastChallenged: '2021-01-02T00:00:00Z', }); }); }); From 7ad103aa45b79455afd629d0433df2e369d72f54 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 17:36:18 +0900 Subject: [PATCH 142/188] =?UTF-8?q?[BE]=20feat:=20CategoryResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - id 프로퍼티를 추가함 --- BE/src/category/dto/category-metadata.ts | 2 +- BE/src/category/dto/category.response.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/BE/src/category/dto/category-metadata.ts b/BE/src/category/dto/category-metadata.ts index 93e97b98..3fcc656e 100644 --- a/BE/src/category/dto/category-metadata.ts +++ b/BE/src/category/dto/category-metadata.ts @@ -9,7 +9,7 @@ export class CategoryMetaData { constructor(categoryMetaData: ICategoryMetaData) { this.categoryId = categoryMetaData.categoryId; this.categoryName = categoryMetaData.categoryName; - this.insertedAt = new Date(categoryMetaData.insertedAt); + this.insertedAt = categoryMetaData.insertedAt ? new Date() : null; this.achievementCount = isNaN(Number(categoryMetaData.achievementCount)) ? 0 : parseInt(categoryMetaData.achievementCount); diff --git a/BE/src/category/dto/category.response.ts b/BE/src/category/dto/category.response.ts index 1fd861a9..ddc1ecdd 100644 --- a/BE/src/category/dto/category.response.ts +++ b/BE/src/category/dto/category.response.ts @@ -2,14 +2,18 @@ import { ApiProperty } from '@nestjs/swagger'; import { Category } from '../domain/category.domain'; export class CategoryResponse { + @ApiProperty({ description: '카테고리 아이디' }) + id: number; + @ApiProperty({ description: '카테고리 이름' }) name: string; - constructor(name: string) { + constructor(id: number, name: string) { + this.id = id; this.name = name; } static from(category: Category) { - return new CategoryResponse(category.name); + return new CategoryResponse(category.id, category.name); } } From 5e61b3804ee8f8736c4c72f84f2e72073dcc5099 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 17:40:07 +0900 Subject: [PATCH 143/188] =?UTF-8?q?[BE]=20fix:=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=98=20=EB=AA=BB=20=EB=90=98=EC=97=88=EC=9D=84?= =?UTF-8?q?=20=EB=95=8C=20=EB=B0=9C=EC=83=9D=EB=90=98=EB=8A=94=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/decorator/athenticated-user.decorator.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/BE/src/auth/decorator/athenticated-user.decorator.ts b/BE/src/auth/decorator/athenticated-user.decorator.ts index 4ce27c08..a477c10c 100644 --- a/BE/src/auth/decorator/athenticated-user.decorator.ts +++ b/BE/src/auth/decorator/athenticated-user.decorator.ts @@ -1,20 +1,11 @@ -import { - createParamDecorator, - ExecutionContext, - InternalServerErrorException, -} from '@nestjs/common'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { InvalidTokenException } from '../exception/invalid-token.exception'; export const AuthenticatedUser = createParamDecorator( (data, context: ExecutionContext) => { const req = context.switchToHttp().getRequest(); - const user = req.user; - - if (!user) { - throw new InternalServerErrorException( - 'request user 프로퍼티가 없습니다.', - ); - } + if (!user) throw new InvalidTokenException(); return user; }, From 92e6f8f8ee2d0e1c273da17964d188becf36fff0 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 18:43:26 +0900 Subject: [PATCH 144/188] =?UTF-8?q?[BE]=20fix:=20categoryResponse=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/category/dto/category-list-regacy.response.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BE/src/category/dto/category-list-regacy.response.spec.ts b/BE/src/category/dto/category-list-regacy.response.spec.ts index 655d09a0..b0bb76e9 100644 --- a/BE/src/category/dto/category-list-regacy.response.spec.ts +++ b/BE/src/category/dto/category-list-regacy.response.spec.ts @@ -47,7 +47,7 @@ describe('CategoryListLegacyResponse', () => { }); describe('생성된 카테고리가 있을 때 응답이 가능하다.', () => { - it('', () => { + it('다수의 카테고리에 대해 응답이 가능하다.', () => { // given const categoryMetaData: CategoryMetaData[] = []; categoryMetaData.push( From acefd389927e42e50724323224e20b3953a6dcb5 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 18:43:52 +0900 Subject: [PATCH 145/188] =?UTF-8?q?[BE]=20fix:=20CategoryMetaData=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/category/dto/category-metadata.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BE/src/category/dto/category-metadata.ts b/BE/src/category/dto/category-metadata.ts index 3fcc656e..530dbcc2 100644 --- a/BE/src/category/dto/category-metadata.ts +++ b/BE/src/category/dto/category-metadata.ts @@ -9,7 +9,9 @@ export class CategoryMetaData { constructor(categoryMetaData: ICategoryMetaData) { this.categoryId = categoryMetaData.categoryId; this.categoryName = categoryMetaData.categoryName; - this.insertedAt = categoryMetaData.insertedAt ? new Date() : null; + this.insertedAt = categoryMetaData.insertedAt + ? new Date(categoryMetaData.insertedAt) + : null; this.achievementCount = isNaN(Number(categoryMetaData.achievementCount)) ? 0 : parseInt(categoryMetaData.achievementCount); From 8fdca8aafbff588ca3532cf081bee6659e88ba18 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 19:33:44 +0900 Subject: [PATCH 146/188] =?UTF-8?q?[BE]=20feat:=20dateFormat=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 값이 없을 때 undefined이 아닌 null을 반환하도록 적용 --- BE/src/common/utils/date-formatter.spec.ts | 23 ++++++++++++++++++++++ BE/src/common/utils/date-formatter.ts | 3 +++ 2 files changed, 26 insertions(+) create mode 100644 BE/src/common/utils/date-formatter.spec.ts create mode 100644 BE/src/common/utils/date-formatter.ts diff --git a/BE/src/common/utils/date-formatter.spec.ts b/BE/src/common/utils/date-formatter.spec.ts new file mode 100644 index 00000000..a601e402 --- /dev/null +++ b/BE/src/common/utils/date-formatter.spec.ts @@ -0,0 +1,23 @@ +import { dateFormat } from './date-formatter'; + +describe('DateFormatter', () => { + it('날짜 포맷이 적용되어야 한다.', () => { + // given + const date = new Date('2021-01-01T00:00:00.000Z'); + + // when + const formattedDate = dateFormat(date); + + // then + expect(formattedDate).toBe('2021-01-01T00:00:00Z'); + }); + + it('날짜 포맷이 적용되어야 한다.', () => { + // given + // when + const formattedDate = dateFormat(undefined); + + // then + expect(formattedDate).toBeNull(); + }); +}); diff --git a/BE/src/common/utils/date-formatter.ts b/BE/src/common/utils/date-formatter.ts new file mode 100644 index 00000000..0d675112 --- /dev/null +++ b/BE/src/common/utils/date-formatter.ts @@ -0,0 +1,3 @@ +export function dateFormat(date: Date): string { + return date?.toISOString().slice(0, -5).concat('Z') || null; +} From 18b2678fa4657089394d4997bf1d930871e79986 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 19:34:12 +0900 Subject: [PATCH 147/188] =?UTF-8?q?[BE]=20feat:=20=EA=B8=B0=EC=A1=B4=20API?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=82=A0=EC=A7=9C=20=ED=8F=AC=EB=A9=A7=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/category/dto/category-list-element.response.ts | 5 +++-- BE/src/category/dto/category-list-legacy.response.ts | 3 ++- BE/src/category/dto/category-list-regacy.response.spec.ts | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/BE/src/category/dto/category-list-element.response.ts b/BE/src/category/dto/category-list-element.response.ts index 102544e8..f02de47c 100644 --- a/BE/src/category/dto/category-list-element.response.ts +++ b/BE/src/category/dto/category-list-element.response.ts @@ -1,4 +1,5 @@ import { CategoryMetaData } from './category-metadata'; +import { dateFormat } from '../../common/utils/date-formatter'; export class CategoryListElementResponse { id: number; @@ -10,7 +11,7 @@ export class CategoryListElementResponse { this.id = category.categoryId; this.name = category.categoryName; this.continued = category.achievementCount; - this.lastChallenged = category.insertedAt?.toISOString() || null; + this.lastChallenged = dateFormat(category.insertedAt); } static totalCategoryElement() { @@ -34,7 +35,7 @@ export class CategoryListElementResponse { !totalItem.lastChallenged || new Date(totalItem.lastChallenged) < category.insertedAt ) - totalItem.lastChallenged = category.insertedAt.toISOString(); + totalItem.lastChallenged = dateFormat(category.insertedAt); totalItem.continued += category.achievementCount; categories.push(new CategoryListElementResponse(category)); diff --git a/BE/src/category/dto/category-list-legacy.response.ts b/BE/src/category/dto/category-list-legacy.response.ts index 344478cd..6207246d 100644 --- a/BE/src/category/dto/category-list-legacy.response.ts +++ b/BE/src/category/dto/category-list-legacy.response.ts @@ -1,6 +1,7 @@ import { CategoryMetaData } from './category-metadata'; import { ApiProperty } from '@nestjs/swagger'; import { CategoryListElementResponse } from './category-list-element.response'; +import { dateFormat } from '../../common/utils/date-formatter'; interface CategoryLegacyList { [key: string]: CategoryListElementResponse; @@ -24,7 +25,7 @@ export class CategoryListLegacyResponse { !totalItem.lastChallenged || new Date(totalItem.lastChallenged) < category.insertedAt ) - totalItem.lastChallenged = category.insertedAt.toISOString(); + totalItem.lastChallenged = dateFormat(category.insertedAt); totalItem.continued += category.achievementCount; this.categories[category.categoryName] = new CategoryListElementResponse( diff --git a/BE/src/category/dto/category-list-regacy.response.spec.ts b/BE/src/category/dto/category-list-regacy.response.spec.ts index b0bb76e9..70f2d0bf 100644 --- a/BE/src/category/dto/category-list-regacy.response.spec.ts +++ b/BE/src/category/dto/category-list-regacy.response.spec.ts @@ -86,19 +86,19 @@ describe('CategoryListLegacyResponse', () => { id: 0, name: '전체', continued: 3, - lastChallenged: '2021-01-02T00:00:00.000Z', + lastChallenged: '2021-01-02T00:00:00Z', }); expect(categoryListResponse.categories['카테고리1']).toEqual({ id: 1, name: '카테고리1', continued: 1, - lastChallenged: '2021-01-01T00:00:00.000Z', + lastChallenged: '2021-01-01T00:00:00Z', }); expect(categoryListResponse.categories['카테고리2']).toEqual({ id: 2, name: '카테고리2', continued: 2, - lastChallenged: '2021-01-02T00:00:00.000Z', + lastChallenged: '2021-01-02T00:00:00Z', }); }); }); From 369a94713f773f3173852f6e82d79cebcbfc5af3 Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Wed, 22 Nov 2023 19:42:46 +0900 Subject: [PATCH 148/188] =?UTF-8?q?[BE]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20API=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=82=A0=EC=A7=9C=20=ED=8F=AC=EB=A9=A7=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/src/achievement/dto/achievement-detail-response.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/BE/src/achievement/dto/achievement-detail-response.ts b/BE/src/achievement/dto/achievement-detail-response.ts index 9469d31b..002e97ca 100644 --- a/BE/src/achievement/dto/achievement-detail-response.ts +++ b/BE/src/achievement/dto/achievement-detail-response.ts @@ -1,6 +1,7 @@ import { IAchievementDetail } from '../index'; import { ApiProperty } from '@nestjs/swagger'; import { CategoryInfo } from './category-info'; +import { dateFormat } from '../../common/utils/date-formatter'; export class AchievementDetailResponse { // @ApiProperty({ type: [AchievementResponse], description: 'data' }) @@ -13,7 +14,7 @@ export class AchievementDetailResponse { @ApiProperty({ description: 'content' }) content: string; @ApiProperty({ description: 'createdAt' }) - createdAt: Date; + createdAt: string; @ApiProperty({ type: CategoryInfo, description: 'data' }) category: CategoryInfo; constructor(achievementDetail: IAchievementDetail) { @@ -21,7 +22,7 @@ export class AchievementDetailResponse { this.title = achievementDetail.title; this.content = achievementDetail.content; this.imageUrl = achievementDetail.imageUrl; - this.createdAt = new Date(achievementDetail.createdAt); + this.createdAt = dateFormat(new Date(achievementDetail.createdAt)); this.category = new CategoryInfo( achievementDetail.categoryId, achievementDetail.categoryName, From 3422464777423b8d40345725ae1647968779c4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 19:50:16 +0900 Subject: [PATCH 149/188] =?UTF-8?q?[iOS]=20refactor:=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Sources/Presentation/Home/HomeViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index f429ded5..a435c77b 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -115,7 +115,7 @@ final class HomeViewModel { addCategoryState = .finish categories.append(category) } else { - addCategoryState = .error(message: "카테고리 추가를 실패했습니다.") + addCategoryState = .error(message: "카테고리 추가에 실패했습니다.") } } } From 98626fcd52db4a48977849cd9d33576478504e78 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Wed, 22 Nov 2023 23:23:15 +0900 Subject: [PATCH 150/188] =?UTF-8?q?[BE]=20feat:=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=20=EC=84=9C=EB=B2=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8B=A4=EC=9A=B4=EC=83=98?= =?UTF-8?q?=ED=94=8C=EB=A7=81=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dltmd202 --- BE/scripts/down-sampling/.gitignore | 1 + BE/scripts/down-sampling/__main__.py | 36 +++++++++++++++++++++++ BE/scripts/down-sampling/requirements.txt | 8 +++++ 3 files changed, 45 insertions(+) create mode 100644 BE/scripts/down-sampling/.gitignore create mode 100644 BE/scripts/down-sampling/__main__.py create mode 100644 BE/scripts/down-sampling/requirements.txt diff --git a/BE/scripts/down-sampling/.gitignore b/BE/scripts/down-sampling/.gitignore new file mode 100644 index 00000000..66072c76 --- /dev/null +++ b/BE/scripts/down-sampling/.gitignore @@ -0,0 +1 @@ +virtualenv diff --git a/BE/scripts/down-sampling/__main__.py b/BE/scripts/down-sampling/__main__.py new file mode 100644 index 00000000..1a0a0450 --- /dev/null +++ b/BE/scripts/down-sampling/__main__.py @@ -0,0 +1,36 @@ +import os + +import boto3 +from PIL import Image + +service_name: str = 's3' +endpoint_url: str = 'https://kr.object.ncloudstorage.com' + + +def downsample_image(input_path: str, output_path: str, downsample_size: tuple[int, int] = (500, 500)): + if not os.path.exists(os.path.dirname(output_path)): + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with Image.open(input_path) as img: + downsampled_img: Image.Image = img.resize(downsample_size, Image.Resampling.LANCZOS) + downsampled_img.save(output_path) + + +def main(args): + bucket_name: str = args['container_name'] + object_name: str = args['object_name'] + + s3: boto3 = boto3.client( + service_name, + endpoint_url=endpoint_url, + aws_access_key_id=args['access_key'], + aws_secret_access_key=args['scret_key'], + ) + + object_path: str = f"./{object_name}" + thumbnail_path: str = f"./thumbnail/{object_name}" + + s3.download_file(bucket_name, object_name, object_path) + downsample_image(object_path, thumbnail_path) + s3.upload_file(thumbnail_path, bucket_name, thumbnail_path) + + return args \ No newline at end of file diff --git a/BE/scripts/down-sampling/requirements.txt b/BE/scripts/down-sampling/requirements.txt new file mode 100644 index 00000000..df58d2da --- /dev/null +++ b/BE/scripts/down-sampling/requirements.txt @@ -0,0 +1,8 @@ +boto3==1.29.5 +botocore==1.32.5 +jmespath==1.0.1 +Pillow==10.1.0 +python-dateutil==2.8.2 +s3transfer==0.7.0 +six==1.16.0 +urllib3==2.0.7 \ No newline at end of file From 4618a01a4320c12cf2a2ca23e61d6b9b5a769b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 23:35:11 +0900 Subject: [PATCH 151/188] =?UTF-8?q?[iOS]=20feat:=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EC=9D=84=20=EB=82=B4=EB=A6=AC=EB=A9=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=EB=8B=AC=EC=84=B1=EA=B8=B0=EB=A1=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Provider/Provider.swift | 6 +- .../AchievementListRepository.swift | 18 ++- .../AchievementListRepositoryTests.swift | 110 +++++++++++++++++- .../Mock/MockAchievementListRepository.swift | 107 +++++------------ .../AchievementListRepositoryProtocol.swift | 4 +- .../UseCase/FetchAchievementListUseCase.swift | 10 +- .../Home/HomeViewController.swift | 29 ++++- .../Presentation/Home/HomeViewModel.swift | 37 +++++- 8 files changed, 228 insertions(+), 93 deletions(-) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift b/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift index b5b12ef4..cd76a837 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Provider/Provider.swift @@ -34,11 +34,13 @@ public struct Provider: ProviderProtocol { throw NetworkError.url } - Logger.network("[Request(\(endpoint.method.rawValue)) \(endpoint.path)]") + #if DEBUG + Logger.network("[Request(\(endpoint.method.rawValue)) \(urlRequest.url!.absoluteString)]") if let requestBody = urlRequest.httpBody, let jsonString = String(data: requestBody, encoding: .utf8) { Logger.network("[요청 데이터]\n\(jsonString)") } + #endif let (data, response) = try await session.data(for: urlRequest) guard let response = response as? HTTPURLResponse else { throw NetworkError.response } @@ -46,11 +48,13 @@ public struct Provider: ProviderProtocol { let statusCode = response.statusCode let body = try decoder.decode(type, from: data) + #if DEBUG Logger.network("[Response(\(statusCode))]") if let encodingData = try? encoder.encode(body), let jsonString = String(data: encodingData, encoding: .utf8) { Logger.network("[응답 데이터]\n\(jsonString)") } + #endif switch statusCode { case 200..<300: diff --git a/iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift b/iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift index 7f223f1d..abd19812 100644 --- a/iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/AchievementListRepository.swift @@ -16,12 +16,26 @@ public struct AchievementListRepository: AchievementListRepositoryProtocol { self.provider = provider } - public func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) async throws -> [Achievement] { + public func fetchAchievementList( + requestValue: FetchAchievementListRequestValue? = nil + ) async throws -> ([Achievement], FetchAchievementListRequestValue?) { let endpoint = MotiAPI.fetchAchievementList(requestValue: requestValue) let responseDTO = try await provider.request(with: endpoint, type: AchievementListResponseDTO.self) guard let achievementListDataDTO = responseDTO.data else { throw NetworkError.decode } guard let achievementListDTO = achievementListDataDTO.data else { throw NetworkError.decode } - return achievementListDTO.map { Achievement(dto: $0) } + + let achievements = achievementListDTO.map { Achievement(dto: $0) } + if let nextDTO = achievementListDataDTO.next { + let next = FetchAchievementListRequestValue( + categoryId: nextDTO.categoryId ?? -1, + take: nextDTO.take ?? -1, + whereIdLessThan: nextDTO.whereIdLessThan ?? -1 + ) + + return (achievements, next) + } else { + return (achievements, nil) + } } } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift index 43c47a5a..e9dcba7a 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift @@ -11,6 +11,91 @@ import XCTest final class AchievementListRepositoryTests: XCTestCase { + let json = """ + { + "success": true, + "data": { + "data": [ + { + "id": 300, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "tend" + }, + { + "id": 299, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "yard" + }, + { + "id": 298, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "improve" + }, + { + "id": 297, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "tonight" + }, + { + "id": 296, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "drug" + }, + { + "id": 295, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "plan" + }, + { + "id": 294, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", + "title": "number" + }, + { + "id": 293, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "help" + }, + { + "id": 292, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "box" + }, + { + "id": 291, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "above" + }, + { + "id": 290, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", + "title": "woman" + }, + { + "id": 289, + "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", + "title": "accept" + } + ], + "count": 12, + "next": { + "take": 12, + "whereIdLessThan": 289 + } + } + } + """ + + let emptyDataJson = """ + { + "success" : true, + "data" : { + "count" : 0, + "data" : [] + } + } + """ + override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } @@ -19,9 +104,8 @@ final class AchievementListRepositoryTests: XCTestCase { // Put teardown code here. This method is called after the invocation of each test method in the class. } - // TODO: xcconfig baseURL nil error func test_queryParam_없이_AchievementList_요청시_nil을_return하는가() throws { - let repository = MockAchievementListRepository() + let repository = MockAchievementListRepository(json: json) let expectation = XCTestExpectation(description: "test_queryParam_없이_AchievementList_요청시_nil을_return하는가") Task { @@ -34,5 +118,27 @@ final class AchievementListRepositoryTests: XCTestCase { wait(for: [expectation], timeout: 3) } + + func test_빈_AchievementList_응답을_디코딩하면_빈배열_return() throws { + let emptyAchievementList: [Achievement] = [] + + let repository = MockAchievementListRepository(json: emptyDataJson) + let expectation = XCTestExpectation(description: "test_빈_AchievementList_응답을_디코딩하면_빈배열_return") + + Task { + let result = try await repository.fetchAchievementList() + + let achievements = result.0 + let next = result.1 + + XCTAssertEqual(achievements, emptyAchievementList) + XCTAssertNil(next) + + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3) + } } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift index 25679878..3a82254a 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockAchievementListRepository.swift @@ -10,88 +10,33 @@ import Foundation @testable import Data public struct MockAchievementListRepository: AchievementListRepositoryProtocol { - public init() { } - public func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) async throws -> [Achievement] { - let json = """ - { - "success": true, - "data": { - "data": [ - { - "id": 300, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", - "title": "tend" - }, - { - "id": 299, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", - "title": "yard" - }, - { - "id": 298, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", - "title": "improve" - }, - { - "id": 297, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", - "title": "tonight" - }, - { - "id": 296, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", - "title": "drug" - }, - { - "id": 295, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", - "title": "plan" - }, - { - "id": 294, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study3_thumb.jpg", - "title": "number" - }, - { - "id": 293, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", - "title": "help" - }, - { - "id": 292, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", - "title": "box" - }, - { - "id": 291, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", - "title": "above" - }, - { - "id": 290, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study2_thumb.jpg", - "title": "woman" - }, - { - "id": 289, - "thumbnailUrl": "https://kr.object.ncloudstorage.com/motimate/study1_thumb.jpg", - "title": "accept" - } - ], - "count": 12, - "next": { - "take": 12, - "whereIdLessThan": 289 - } - } - } - """ - + private let json: String + + public init(json: String) { + self.json = json + } + + public func fetchAchievementList( + requestValue: FetchAchievementListRequestValue? = nil + ) async throws -> ([Achievement], FetchAchievementListRequestValue?) { guard let testData = json.data(using: .utf8) else { throw NetworkError.decode } - let achievementListResponseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) - guard let achievementListResponseDataDTO = achievementListResponseDTO.data else { throw NetworkError.decode } - guard let achievementSimpleDTOs = achievementListResponseDataDTO.data else { throw NetworkError.decode } - return achievementSimpleDTOs.map { Achievement(dto: $0) } + let responseDTO = try JSONDecoder().decode(AchievementListResponseDTO.self, from: testData) + guard let achievementListDataDTO = responseDTO.data else { throw NetworkError.decode } + + guard let achievementListDTO = achievementListDataDTO.data else { throw NetworkError.decode } + let achievements = achievementListDTO.map { Achievement(dto: $0) } + + if let nextDTO = achievementListDataDTO.next { + let next = FetchAchievementListRequestValue( + categoryId: nextDTO.categoryId ?? -1, + take: nextDTO.take ?? -1, + whereIdLessThan: nextDTO.whereIdLessThan ?? -1 + ) + + return (achievements, next) + } else { + return (achievements, nil) + } } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift index 4dc66775..b9181148 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AchievementListRepositoryProtocol.swift @@ -8,5 +8,7 @@ import Foundation public protocol AchievementListRepositoryProtocol { - func fetchAchievementList(requestValue: FetchAchievementListRequestValue?) async throws -> [Achievement] + func fetchAchievementList( + requestValue: FetchAchievementListRequestValue? + ) async throws -> ([Achievement], FetchAchievementListRequestValue?) } diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift index 26d80c4a..32e87425 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift @@ -11,6 +11,12 @@ public struct FetchAchievementListRequestValue: RequestValue { public let categoryId: Int public let take: Int public let whereIdLessThan: Int + + public init(categoryId: Int, take: Int, whereIdLessThan: Int) { + self.categoryId = categoryId + self.take = take + self.whereIdLessThan = whereIdLessThan + } } public struct FetchAchievementListUseCase { @@ -20,7 +26,9 @@ public struct FetchAchievementListUseCase { self.repository = repository } - public func execute(requestValue: FetchAchievementListRequestValue? = nil) async throws -> [Achievement] { + public func execute( + requestValue: FetchAchievementListRequestValue? = nil + ) async throws -> ([Achievement], FetchAchievementListRequestValue?) { return try await repository.fetchAchievementList(requestValue: requestValue) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index c34b3860..9a19c3d0 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -15,6 +15,7 @@ final class HomeViewController: BaseViewController { weak var coordinator: HomeCoordinator? private let viewModel: HomeViewModel private var cancellables: Set = [] + private var isFetchingNextPage = false init(viewModel: HomeViewModel) { self.viewModel = viewModel @@ -41,9 +42,19 @@ final class HomeViewController: BaseViewController { private func bind() { viewModel.$achievementState .receive(on: DispatchQueue.main) - .sink { state in + .sink { [weak self] state in + guard let self else { return } // state 에 따른 뷰 처리 - 스켈레톤 뷰, fetch 에러 뷰 등 Logger.debug(state) + switch state { + case .finish: + isFetchingNextPage = false + case .error(let message): + isFetchingNextPage = false + Logger.error("Fetch Achievement Error: \(message)") + default: break + } + } .store(in: &cancellables) @@ -72,7 +83,6 @@ final class HomeViewController: BaseViewController { case .none: break case .loading: layoutView.catergoryAddButton.isEnabled = false - break case .finish: layoutView.catergoryAddButton.isEnabled = true case .error(let message): @@ -186,7 +196,7 @@ extension HomeViewController: UICollectionViewDelegate { cell.transform = .identity }) - let category = viewModel.findCategory(at: row) + let category = viewModel.selectedCategory(at: row) Logger.debug("Selected Category: \(category.name)") layoutView.updateAchievementHeader(with: category) } @@ -195,4 +205,17 @@ extension HomeViewController: UICollectionViewDelegate { guard let cell = cell as? AchievementCollectionViewCell else { return } cell.cancelDownloadImage() } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let actualPos = scrollView.panGestureRecognizer.translation(in: scrollView.superview) + let pos = scrollView.contentOffset.y + let diff = layoutView.achievementCollectionView.contentSize.height - scrollView.frame.size.height + + // 아래로 드래그 && 마지막까지 스크롤 + if actualPos.y < 0 && pos > diff && !isFetchingNextPage { + Logger.debug("Fetch New Data") + isFetchingNextPage = true + viewModel.action(.fetchNextPage) + } + } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index a435c77b..263ab09f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -7,11 +7,13 @@ import Foundation import Domain +import Core final class HomeViewModel { enum HomeViewModelAction { case launch case addCategory(name: String) + case fetchNextPage } enum CategoryState { @@ -29,6 +31,7 @@ final class HomeViewModel { enum AchievementState { case initial + case loading case finish case error(message: String) } @@ -53,6 +56,8 @@ final class HomeViewModel { achievementDataSource?.update(data: achievements) } } + private var currentCategoryId: Int = 0 + private var nextRequestValue: FetchAchievementListRequestValue? @Published private(set) var categoryState: CategoryState = .initial @Published private(set) var addCategoryState: AddCategoryState = .none @@ -75,6 +80,8 @@ final class HomeViewModel { fetchAchievementList() case .addCategory(let name): addCategory(name: name) + case .fetchNextPage: + fetchNextAchievementList() } } @@ -91,7 +98,8 @@ final class HomeViewModel { return achievements[index] } - func findCategory(at index: Int) -> CategoryItem { + func selectedCategory(at index: Int) -> CategoryItem { + currentCategoryId = categories[index].id return categories[index] } @@ -123,7 +131,32 @@ final class HomeViewModel { private func fetchAchievementList() { Task { do { - achievements = try await fetchAchievementListUseCase.execute() + achievementState = .loading + (achievements, nextRequestValue) = try await fetchAchievementListUseCase.execute() + achievementState = .finish + } catch { + achievementState = .error(message: error.localizedDescription) + } + } + } + + private func fetchNextAchievementList() { + guard let requestValue = nextRequestValue else { + Logger.debug("마지막 페이지입니다.") + return + } + + Task { + do { + achievementState = .loading + let requestValue = FetchAchievementListRequestValue( + categoryId: currentCategoryId, + take: requestValue.take, + whereIdLessThan: requestValue.whereIdLessThan + ) + let (newAchievements, next) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) + achievements.append(contentsOf: newAchievements) + nextRequestValue = next achievementState = .finish } catch { achievementState = .error(message: error.localizedDescription) From b6622a5b3d8b732c39310792c4137fcfd2ad864a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Wed, 22 Nov 2023 23:52:31 +0900 Subject: [PATCH 152/188] =?UTF-8?q?[iOS]=20feat:=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EC=9D=98=20?= =?UTF-8?q?=EB=8B=AC=EC=84=B1=EA=B8=B0=EB=A1=9D=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCase/FetchAchievementListUseCase.swift | 8 ++-- .../Home/HomeViewController.swift | 3 +- .../Presentation/Home/HomeViewModel.swift | 39 ++++++++----------- 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift index 32e87425..62f1e511 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchAchievementListUseCase.swift @@ -9,10 +9,10 @@ import Foundation public struct FetchAchievementListRequestValue: RequestValue { public let categoryId: Int - public let take: Int - public let whereIdLessThan: Int + public let take: Int? + public let whereIdLessThan: Int? - public init(categoryId: Int, take: Int, whereIdLessThan: Int) { + public init(categoryId: Int, take: Int?, whereIdLessThan: Int?) { self.categoryId = categoryId self.take = take self.whereIdLessThan = whereIdLessThan @@ -28,7 +28,7 @@ public struct FetchAchievementListUseCase { public func execute( requestValue: FetchAchievementListRequestValue? = nil - ) async throws -> ([Achievement], FetchAchievementListRequestValue?) { + ) async throws -> ([Achievement], FetchAchievementListRequestValue?) { return try await repository.fetchAchievementList(requestValue: requestValue) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 9a19c3d0..5a19273a 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -196,8 +196,9 @@ extension HomeViewController: UICollectionViewDelegate { cell.transform = .identity }) - let category = viewModel.selectedCategory(at: row) + let category = viewModel.findCategory(at: row) Logger.debug("Selected Category: \(category.name)") + viewModel.action(.fetchCategoryList(category: category)) layoutView.updateAchievementHeader(with: category) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 263ab09f..6951f87c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -14,6 +14,7 @@ final class HomeViewModel { case launch case addCategory(name: String) case fetchNextPage + case fetchCategoryList(category: CategoryItem) } enum CategoryState { @@ -56,7 +57,6 @@ final class HomeViewModel { achievementDataSource?.update(data: achievements) } } - private var currentCategoryId: Int = 0 private var nextRequestValue: FetchAchievementListRequestValue? @Published private(set) var categoryState: CategoryState = .initial @@ -77,11 +77,12 @@ final class HomeViewModel { switch action { case .launch: fetchCategories() - fetchAchievementList() case .addCategory(let name): addCategory(name: name) case .fetchNextPage: fetchNextAchievementList() + case .fetchCategoryList(let category): + fetchCategoryAchievementList(category: category) } } @@ -98,8 +99,7 @@ final class HomeViewModel { return achievements[index] } - func selectedCategory(at index: Int) -> CategoryItem { - currentCategoryId = categories[index].id + func findCategory(at index: Int) -> CategoryItem { return categories[index] } @@ -128,11 +128,13 @@ final class HomeViewModel { } } - private func fetchAchievementList() { + private func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) { Task { do { achievementState = .loading - (achievements, nextRequestValue) = try await fetchAchievementListUseCase.execute() + let (achievements, nextRequestValue) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) + self.achievements.append(contentsOf: achievements) + self.nextRequestValue = nextRequestValue achievementState = .finish } catch { achievementState = .error(message: error.localizedDescription) @@ -140,27 +142,20 @@ final class HomeViewModel { } } + private func fetchCategoryAchievementList(category: CategoryItem) { + // 새로운 카테고리 데이터를 가져오기 때문에 빈 배열로 초기화 + achievements = [] + + let requestValue = FetchAchievementListRequestValue(categoryId: category.id, take: nil, whereIdLessThan: nil) + fetchAchievementList(requestValue: requestValue) + } + private func fetchNextAchievementList() { guard let requestValue = nextRequestValue else { Logger.debug("마지막 페이지입니다.") return } - Task { - do { - achievementState = .loading - let requestValue = FetchAchievementListRequestValue( - categoryId: currentCategoryId, - take: requestValue.take, - whereIdLessThan: requestValue.whereIdLessThan - ) - let (newAchievements, next) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) - achievements.append(contentsOf: newAchievements) - nextRequestValue = next - achievementState = .finish - } catch { - achievementState = .error(message: error.localizedDescription) - } - } + fetchAchievementList(requestValue: requestValue) } } From 85de8fcc97692dbb2e2dfe373c0c39e9ec8d6f90 Mon Sep 17 00:00:00 2001 From: lsh23 Date: Thu, 23 Nov 2023 00:01:57 +0900 Subject: [PATCH 153/188] =?UTF-8?q?[BE]=20fix:=20categoryId=200=EC=9D=B8?= =?UTF-8?q?=20=EA=B2=BD=EC=9A=B0=EC=97=90=EB=8F=84=20next=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EC=96=B4=EC=A3=BC=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/paginate-achievement-response.spec.ts | 16 ++++++++++++++++ .../dto/paginate-achievement-response.ts | 3 --- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/BE/src/achievement/dto/paginate-achievement-response.spec.ts b/BE/src/achievement/dto/paginate-achievement-response.spec.ts index dd9300e2..bac21694 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.spec.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.spec.ts @@ -20,6 +20,7 @@ describe('PaginateAchievementResponse test', () => { ); expect(response.next).toEqual({ take: 12, whereIdLessThan: 88 }); }); + test('다음 페이지 네이션 요청을 위한 정보를 가지고 있는 next을 생성한다. - categoryId 필터링 추가', () => { const paginateAchievementRequest = new PaginateAchievementRequest(); paginateAchievementRequest.take = 12; @@ -34,4 +35,19 @@ describe('PaginateAchievementResponse test', () => { whereIdLessThan: 88, }); }); + + test('다음 페이지 네이션 요청을 위한 정보를 가지고 있는 next을 생성한다. - categoryId가 0인 경우', () => { + const paginateAchievementRequest = new PaginateAchievementRequest(); + paginateAchievementRequest.take = 12; + paginateAchievementRequest.categoryId = 0; + const response = new PaginateAchievementResponse( + paginateAchievementRequest, + achievementResponses.slice(0, paginateAchievementRequest.take), + ); + expect(response.next).toEqual({ + categoryId: 0, + take: 12, + whereIdLessThan: 88, + }); + }); }); diff --git a/BE/src/achievement/dto/paginate-achievement-response.ts b/BE/src/achievement/dto/paginate-achievement-response.ts index 73a59e66..4cf56efe 100644 --- a/BE/src/achievement/dto/paginate-achievement-response.ts +++ b/BE/src/achievement/dto/paginate-achievement-response.ts @@ -35,9 +35,6 @@ export class PaginateAchievementResponse { if (next) { for (const key of Object.keys(paginateAchievementRequest)) { - if (!paginateAchievementRequest[key]) { - continue; - } if (key !== 'whereIdLessThan') { next[key] = paginateAchievementRequest[key]; } From 694a8c31b5586a6d2b73b1a0937d66f33e48a4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 00:16:49 +0900 Subject: [PATCH 154/188] =?UTF-8?q?[iOS]=20feat:=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=8A=94=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20=EC=95=88=20=ED=95=98=EA=B2=8C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Sources/Presentation/Home/HomeView.swift | 2 +- .../Sources/Presentation/Home/HomeViewModel.swift | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift index 4d4b276e..a7ea6332 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift @@ -56,7 +56,7 @@ final class HomeView: UIView { } // MARK: - Methods - func updateAchievementHeader(with category: CategoryItem) { + func updateAchievementHeader(with category: CategoryItem) { guard let header = achievementCollectionView.visibleSupplementaryViews( ofKind: UICollectionView.elementKindSectionHeader ).first as? HeaderView else { return } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 6951f87c..e1afdf53 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -58,6 +58,7 @@ final class HomeViewModel { } } private var nextRequestValue: FetchAchievementListRequestValue? + private var currentCategory: CategoryItem? @Published private(set) var categoryState: CategoryState = .initial @Published private(set) var addCategoryState: AddCategoryState = .none @@ -143,6 +144,11 @@ final class HomeViewModel { } private func fetchCategoryAchievementList(category: CategoryItem) { + guard currentCategory != category else { + Logger.debug("현재 카테고리입니다.") + return + } + currentCategory = category // 새로운 카테고리 데이터를 가져오기 때문에 빈 배열로 초기화 achievements = [] From f9deb71df330fcf29b555519404852cee2fa153e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 09:31:02 +0900 Subject: [PATCH 155/188] =?UTF-8?q?[iOS]=20fix:=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EC=9D=84=20=EB=82=B4=EB=A6=AC=EA=B3=A0=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=B4=EB=8F=84=20HeaderView=EA=B0=80=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Home/HomeViewController.swift | 9 +++++++++ .../Sources/Presentation/Home/HomeViewModel.swift | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 5a19273a..d89fe221 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -202,6 +202,15 @@ extension HomeViewController: UICollectionViewDelegate { layoutView.updateAchievementHeader(with: category) } + func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) { + guard elementKind == UICollectionView.elementKindSectionHeader, + let headerView = view as? HeaderView else { return } + + if let currentCategory = viewModel.currentCategory { + headerView.configure(category: currentCategory) + } + } + func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard let cell = cell as? AchievementCollectionViewCell else { return } cell.cancelDownloadImage() diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index e1afdf53..36eb0a7c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -58,7 +58,7 @@ final class HomeViewModel { } } private var nextRequestValue: FetchAchievementListRequestValue? - private var currentCategory: CategoryItem? + var currentCategory: CategoryItem? @Published private(set) var categoryState: CategoryState = .initial @Published private(set) var addCategoryState: AddCategoryState = .none From c41c780bbe731bb49b2685e5461fce08cad0a15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 09:57:37 +0900 Subject: [PATCH 156/188] =?UTF-8?q?[iOS]=20fix:=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EC=9D=84=20=EB=82=B4=EB=A6=AC=EA=B3=A0=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=B4=EB=8F=84=20HeaderView=EA=B0=80=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Home/HomeViewController.swift | 9 +++++++++ .../Sources/Presentation/Home/HomeViewModel.swift | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index 5a19273a..d89fe221 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -202,6 +202,15 @@ extension HomeViewController: UICollectionViewDelegate { layoutView.updateAchievementHeader(with: category) } + func collectionView(_ collectionView: UICollectionView, willDisplaySupplementaryView view: UICollectionReusableView, forElementKind elementKind: String, at indexPath: IndexPath) { + guard elementKind == UICollectionView.elementKindSectionHeader, + let headerView = view as? HeaderView else { return } + + if let currentCategory = viewModel.currentCategory { + headerView.configure(category: currentCategory) + } + } + func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard let cell = cell as? AchievementCollectionViewCell else { return } cell.cancelDownloadImage() diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index e1afdf53..37c3f1bb 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -58,7 +58,7 @@ final class HomeViewModel { } } private var nextRequestValue: FetchAchievementListRequestValue? - private var currentCategory: CategoryItem? + private(set) var currentCategory: CategoryItem? @Published private(set) var categoryState: CategoryState = .initial @Published private(set) var addCategoryState: AddCategoryState = .none From 2e19d09a3f9d90af479eca41b43fd89c1975ee23 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 13:27:43 +0900 Subject: [PATCH 157/188] =?UTF-8?q?[iOS]=20refactor:=20=EB=94=94=ED=85=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=8B=AC=EC=84=B1=EA=B8=B0=EB=A1=9D=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievementCoordinator.swift | 0 .../{Detail => DetailAchievement}/DetailAchievementView.swift | 0 .../DetailAchievementViewController.swift | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename iOS/moti/moti/Presentation/Sources/Presentation/{Detail => DetailAchievement}/DetailAchievementCoordinator.swift (100%) rename iOS/moti/moti/Presentation/Sources/Presentation/{Detail => DetailAchievement}/DetailAchievementView.swift (100%) rename iOS/moti/moti/Presentation/Sources/Presentation/{Detail => DetailAchievement}/DetailAchievementViewController.swift (100%) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift similarity index 100% rename from iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementCoordinator.swift rename to iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift similarity index 100% rename from iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift rename to iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift similarity index 100% rename from iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift rename to iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift From c89011e3f444422bd39f84ac01b81586d616e9ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 13:44:45 +0900 Subject: [PATCH 158/188] =?UTF-8?q?[iOS]=20feat:=20EditAchievementVC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureCoordinator.swift | 54 ++------ .../Presentation/Capture/CaptureView.swift | 38 +----- .../Capture/CaptureViewController.swift | 123 +++--------------- .../EditAchievementCoordinator.swift | 56 ++++++++ .../EditAchievementView.swift} | 46 +++---- .../EditAchievementViewController.swift | 116 +++++++++++++++++ 6 files changed, 219 insertions(+), 214 deletions(-) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift rename iOS/moti/moti/Presentation/Sources/Presentation/{Common/AchievementView.swift => EditAchievement/EditAchievementView.swift} (78%) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 6e05be3e..3f5edc33 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -12,7 +12,7 @@ final class CaptureCoordinator: Coordinator { var parentCoordinator: Coordinator? var childCoordinators: [Coordinator] = [] var navigationController: UINavigationController - private var currentViewController: CaptureViewController? + private var currentNavigationController: UINavigationController? init( _ navigationController: UINavigationController, @@ -27,59 +27,31 @@ final class CaptureCoordinator: Coordinator { captureVC.delegate = self captureVC.coordinator = self - currentViewController = captureVC - - changeToCaptureMode() - - let navVC = UINavigationController(rootViewController: captureVC) - navVC.modalPresentationStyle = .fullScreen - navigationController.present(navVC, animated: true) - } - - private func changeToCaptureMode() { - guard let currentViewController = currentViewController else { return } - currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( + captureVC.navigationItem.leftBarButtonItem = UIBarButtonItem( title: "취소", style: .plain, target: self, action: #selector(cancelButtonAction) ) - currentViewController.navigationItem.rightBarButtonItem = nil - } - - private func changeToEditMode() { - guard let currentViewController = currentViewController else { return } - currentViewController.navigationItem.leftBarButtonItem = UIBarButtonItem( - title: "다시 촬영", style: .plain, target: self, - action: #selector(recaptureButtonAction) - ) + captureVC.navigationItem.rightBarButtonItem = nil - currentViewController.navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(doneButtonAction) - ) + navigationController.pushViewController(captureVC, animated: true) + navigationController.setNavigationBarHidden(false, animated: false) } - @objc func cancelButtonAction() { - finish() - } - - @objc func recaptureButtonAction() { - changeToCaptureMode() - currentViewController?.startCapture() + private func moveEditAchievementViewConrtoller(image: UIImage) { + let editAchievementCoordinator = EditAchievementCoordinator(navigationController, self) + editAchievementCoordinator.startAfterCapture(image: image) + childCoordinators.append(editAchievementCoordinator) } - @objc func doneButtonAction() { + @objc func cancelButtonAction() { + navigationController.setNavigationBarHidden(true, animated: false) finish() } - - func finish(animated: Bool = true) { - parentCoordinator?.dismiss(child: self, animated: true) - } } extension CaptureCoordinator: CaptureViewControllerDelegate { - func didCapture() { - changeToEditMode() + func didCapture(image: UIImage) { + moveEditAchievementViewConrtoller(image: image) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 5817f039..4aaee2db 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -40,13 +40,6 @@ final class CaptureView: UIView { return previewLayer }() - // 편집 뷰 - let achievementView = { - let achievementView = AchievementView() - achievementView.isHidden = true - return achievementView - }() - // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) @@ -70,26 +63,6 @@ final class CaptureView: UIView { previewLayer.session = session } - func changeToCaptureMode() { - preview.isHidden = false - albumButton.isHidden = false - cameraSwitchingButton.isHidden = false - captureButton.isHidden = false - - achievementView.resultImageView.image = nil - achievementView.isHidden = true - } - - func changeToEditMode(image: UIImage) { - preview.isHidden = true - albumButton.isHidden = true - cameraSwitchingButton.isHidden = true - captureButton.isHidden = true - - achievementView.isHidden = false - achievementView.configureEdit(image: image) - } - func changeToBackCamera() { cameraSwitchingButton.setImage(SymbolImage.iphone, for: .normal) } @@ -102,7 +75,6 @@ final class CaptureView: UIView { // MARK: - Setup private extension CaptureView { func setupUI() { - setupAchievementView() setupPreview() setupCaptureButton() @@ -134,20 +106,12 @@ private extension CaptureView { .left(equalTo: captureButton.rightAnchor, constant: 30) } - func setupAchievementView() { - addSubview(achievementView) - achievementView.atl - .top(equalTo: safeAreaLayoutGuide.topAnchor) - .bottom(equalTo: bottomAnchor) - .horizontal(equalTo: safeAreaLayoutGuide) - } - func setupPreview() { // 카메라 Preview addSubview(preview) preview.atl .height(equalTo: preview.widthAnchor) - .top(equalTo: achievementView.resultImageView.topAnchor) + .centerY(equalTo: safeAreaLayoutGuide.centerYAnchor, constant: -50) .horizontal(equalTo: safeAreaLayoutGuide) // PreviewLayer를 Preview 에 넣기 diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index aa3eb9f8..fc12b87a 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -12,7 +12,7 @@ import Design import PhotosUI protocol CaptureViewControllerDelegate: AnyObject { - func didCapture() + func didCapture(image: UIImage) } final class CaptureViewController: BaseViewController { @@ -20,9 +20,6 @@ final class CaptureViewController: BaseViewController { // MARK: - Properties weak var delegate: CaptureViewControllerDelegate? weak var coordinator: CaptureCoordinator? - - private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] - private var bottomSheet = TextViewBottomSheet() // Capture Session private var isBackCamera = true @@ -36,7 +33,6 @@ final class CaptureViewController: BaseViewController { // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() - setupCategoryPickerView() addTargets() } @@ -56,36 +52,16 @@ final class CaptureViewController: BaseViewController { } } - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - view.endEditing(true) - } - // MARK: - Methods private func addTargets() { layoutView.captureButton.addTarget(self, action: #selector(didClickedShutterButton), for: .touchUpInside) - layoutView.achievementView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) - layoutView.achievementView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) layoutView.albumButton.addTarget(self, action: #selector(showPHPicker), for: .touchUpInside) layoutView.cameraSwitchingButton.addTarget(self, action: #selector(switchCameraInput), for: .touchUpInside) } - - func startCapture() { - layoutView.achievementView.hideCategoryPicker() - hideBottomSheet() - setupCamera() - layoutView.changeToCaptureMode() - } - - func startEdit(image: UIImage) { - showBottomSheet() - layoutView.changeToEditMode(image: image) - } private func capturedPicture(image: UIImage) { guard let croppedImage = image.cropToSquare() else { return } - startEdit(image: croppedImage) - - delegate?.didCapture() + delegate?.didCapture(image: croppedImage) } } @@ -114,6 +90,14 @@ extension CaptureViewController { } private func setupCamera() { + setupBackCamera() + setupFrontCamera() + + // 아무런 카메라도 없으면 메서드 종료 + if backCameraInput == nil && frontCameraInput == nil { + return + } + // 세션을 만들고 input, output 연결 let session = AVCaptureSession() session.beginConfiguration() @@ -121,14 +105,11 @@ extension CaptureViewController { if session.canSetSessionPreset(.photo) { session.sessionPreset = .photo } - - setupBackCamera(session: session) - setupFrontCamera(session: session) if isBackCamera, - let backCameraInput = backCameraInput { + let backCameraInput = backCameraInput, session.canAddInput(backCameraInput) { session.addInput(backCameraInput) - } else if let frontCameraInput = frontCameraInput { + } else if let frontCameraInput = frontCameraInput, session.canAddInput(frontCameraInput) { session.addInput(frontCameraInput) } @@ -141,16 +122,15 @@ extension CaptureViewController { session.commitConfiguration() self.session = session - + layoutView.updatePreviewLayer(session: session) - layoutView.changeToCaptureMode() if isBackCamera { layoutView.changeToBackCamera() } else { layoutView.changeToFrontCamera() } - DispatchQueue.global().async { + DispatchQueue.global(qos: .userInteractive).async { if !session.isRunning { Logger.debug("Session Start Running") session.startRunning() @@ -161,26 +141,20 @@ extension CaptureViewController { } // 후면 카메라 설정 - private func setupBackCamera(session: AVCaptureSession) { + private func setupBackCamera() { if let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let backCameraInput = try? AVCaptureDeviceInput(device: backCamera) { - if session.canAddInput(backCameraInput) { - Logger.debug("Add Back Camera Input") - self.backCameraInput = backCameraInput - } + self.backCameraInput = backCameraInput } else { Logger.error("후면 카메라를 추가할 수 없음") } } // 전면 카메라 설정 - private func setupFrontCamera(session: AVCaptureSession) { + private func setupFrontCamera() { if let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front), let frontCameraInput = try? AVCaptureDeviceInput(device: frontCamera) { - if session.canAddInput(frontCameraInput) { - Logger.debug("Add Front Camera Input") - self.frontCameraInput = frontCameraInput - } + self.frontCameraInput = frontCameraInput } else { Logger.error("전면 카메라를 추가할 수 없음") } @@ -194,7 +168,6 @@ extension CaptureViewController { Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") capturedPicture(image: MotiImage.sample1) #else - // TODO: PhotoQualityPrioritization 옵션별로 비교해서 최종 결정해야 함 // - speed: 약간의 노이즈 감소만이 적용 // - balanced: speed보다 약간 더 느리지만 더 나은 품질을 얻음 // - quality: 현대 디바이스나 밝기에 따라 많은 시간을 사용하여 최상의 품질을 만듬 @@ -289,63 +262,3 @@ extension CaptureViewController: PHPickerViewControllerDelegate { } } } - -// MARK: - Bottom Sheet -private extension CaptureViewController { - func showBottomSheet() { - bottomSheet.modalPresentationStyle = .pageSheet - - if let sheet = bottomSheet.sheetPresentationController { - sheet.detents = [.small(), .large()] - sheet.prefersGrabberVisible = true - sheet.prefersScrollingExpandsWhenScrolledToEdge = false - sheet.selectedDetentIdentifier = .small - sheet.largestUndimmedDetentIdentifier = .large - } - - bottomSheet.isModalInPresentation = true - present(bottomSheet, animated: true) - } - - func hideBottomSheet() { - bottomSheet.dismiss(animated: true) - } -} - -// MARK: - Category PickerView -extension CaptureViewController { - private func setupCategoryPickerView() { - layoutView.achievementView.categoryPickerView.delegate = self - layoutView.achievementView.categoryPickerView.dataSource = self - } - - @objc private func showPicker() { - hideBottomSheet() - layoutView.achievementView.showCategoryPicker() - } - - @objc private func donePicker() { - layoutView.achievementView.hideCategoryPicker() - showBottomSheet() - } -} - -extension CaptureViewController: UIPickerViewDelegate { - func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { - layoutView.achievementView.update(category: categories[row]) - } -} - -extension CaptureViewController: UIPickerViewDataSource { - func numberOfComponents(in pickerView: UIPickerView) -> Int { - return 1 - } - - func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { - return categories.count - } - - func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { - return categories[row] - } -} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift new file mode 100644 index 00000000..a253d63f --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift @@ -0,0 +1,56 @@ +// +// EditAchievementCoordinator.swift +// +// +// Created by 유정주 on 11/23/23. +// + +import UIKit +import Core + +final class EditAchievementCoordinator: Coordinator { + var parentCoordinator: Coordinator? + var childCoordinators: [Coordinator] = [] + var navigationController: UINavigationController + + init( + _ navigationController: UINavigationController, + _ parentCoordinator: Coordinator? + ) { + self.navigationController = navigationController + self.parentCoordinator = parentCoordinator + } + + func start() { + + } + + func startAfterCapture(image: UIImage) { + let editAchievementVC = EditAchievementViewController(image: image) + editAchievementVC.coordinator = self + + editAchievementVC.navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "다시 촬영", style: .plain, target: self, + action: #selector(recaptureButtonAction) + ) + + editAchievementVC.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonAction) + ) + + navigationController.pushViewController(editAchievementVC, animated: true) + navigationController.setNavigationBarHidden(false, animated: false) + } + + @objc func recaptureButtonAction() { + finish(animated: false) + } + + @objc func doneButtonAction() { + navigationController.setNavigationBarHidden(true, animated: false) + finish(animated: false) + parentCoordinator?.finish(animated: true) + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift similarity index 78% rename from iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift rename to iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift index f981b488..8d63800a 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift @@ -1,13 +1,17 @@ // -// AchievementView.swift -// +// EditAchievementView.swift // -// Created by 유정주 on 11/21/23. // +// Created by 유정주 on 11/23/23. +// + +import UIKit import UIKit +import Design +import Domain -final class AchievementView: UIView { +final class EditAchievementView: UIView { // MARK: - Views let resultImageView: UIImageView = { let imageView = UIImageView() @@ -59,7 +63,7 @@ final class AchievementView: UIView { setupUI() } - func configureEdit(image: UIImage, category: String? = nil) { + func configure(image: UIImage?, category: String? = nil) { resultImageView.image = image if let category { @@ -68,16 +72,6 @@ final class AchievementView: UIView { } } - func configureReadOnly(image: UIImage, title: String, category: String) { - resultImageView.image = image - - titleTextField.text = title - titleTextField.isEnabled = false - - categoryButton.setTitle(category, for: .normal) - categoryButton.isEnabled = false - } - func update(image: UIImage) { resultImageView.image = image } @@ -91,16 +85,6 @@ final class AchievementView: UIView { categoryButton.setTitleColor(.label, for: .normal) } - func editMode() { - titleTextField.isEnabled = true - categoryButton.isEnabled = true - } - - func readOnlyMode() { - titleTextField.isEnabled = false - categoryButton.isEnabled = false - } - func showCategoryPicker() { categoryPickerView.isHidden = false selectDoneButton.isHidden = false @@ -113,11 +97,11 @@ final class AchievementView: UIView { } // MARK: - Setup -extension AchievementView { +extension EditAchievementView { private func setupUI() { - setupCategoryButton() - setupTitleTextField() setupResultImageView() + setupTitleTextField() + setupCategoryButton() setupCategoryPickerView() } @@ -125,14 +109,14 @@ extension AchievementView { addSubview(categoryButton) categoryButton.atl .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + .bottom(equalTo: titleTextField.topAnchor, constant: -5) } private func setupTitleTextField() { addSubview(titleTextField) titleTextField.atl .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) - .top(equalTo: categoryButton.bottomAnchor) + .bottom(equalTo: resultImageView.topAnchor, constant: -10) } private func setupResultImageView() { @@ -140,7 +124,7 @@ extension AchievementView { resultImageView.atl .horizontal(equalTo: safeAreaLayoutGuide) .height(equalTo: resultImageView.widthAnchor) - .top(equalTo: titleTextField.bottomAnchor, constant: 10) + .centerY(equalTo: safeAreaLayoutGuide.centerYAnchor, constant: -50) } private func setupCategoryPickerView() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift new file mode 100644 index 00000000..de8e8662 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift @@ -0,0 +1,116 @@ +// +// EditAchievementViewController.swift +// +// +// Created by 유정주 on 11/23/23. +// + +import UIKit +import Core +import Design + +final class EditAchievementViewController: BaseViewController { + + // MARK: - Properties + weak var coordinator: EditAchievementCoordinator? + private let image: UIImage + + private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] + private var bottomSheet = TextViewBottomSheet() + + // MARK: - Init + init(image: UIImage) { + self.image = image + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycles + override func viewDidLoad() { + super.viewDidLoad() + + layoutView.configure(image: image) + showBottomSheet() + addTarget() + + layoutView.categoryPickerView.delegate = self + layoutView.categoryPickerView.dataSource = self + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + hideBottomSheet() + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + view.endEditing(true) + } + + private func addTarget() { + layoutView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) + layoutView.selectDoneButton.addTarget(self, action: #selector(donePicker), for: .touchUpInside) + } +} + +// MARK: - Bottom Sheet +private extension EditAchievementViewController { + func showBottomSheet() { + bottomSheet.modalPresentationStyle = .pageSheet + + if let sheet = bottomSheet.sheetPresentationController { + sheet.detents = [.small(), .large()] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.selectedDetentIdentifier = .small + sheet.largestUndimmedDetentIdentifier = .large + } + + bottomSheet.isModalInPresentation = true + present(bottomSheet, animated: true) + } + + func hideBottomSheet() { + bottomSheet.dismiss(animated: true) + } +} + +// MARK: - Category PickerView +extension EditAchievementViewController { + private func setupCategoryPickerView() { + layoutView.categoryPickerView.delegate = self + layoutView.categoryPickerView.dataSource = self + } + + @objc private func showPicker() { + hideBottomSheet() + layoutView.showCategoryPicker() + } + + @objc private func donePicker() { + layoutView.hideCategoryPicker() + showBottomSheet() + } +} + +extension EditAchievementViewController: UIPickerViewDelegate { + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + layoutView.update(category: categories[row]) + } +} + +extension EditAchievementViewController: UIPickerViewDataSource { + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return categories.count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return categories[row] + } +} From df7c47740d24a99860653d6aa7f4213ca80b13c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 13:46:14 +0900 Subject: [PATCH 159/188] =?UTF-8?q?[iOS]=20fix:=20AchievementView=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Detail/DetailAchievementView.swift | 12 ++++++------ .../Detail/DetailAchievementViewController.swift | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift index 5cf644e4..34f8a142 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementView.swift @@ -11,7 +11,7 @@ import Design final class DetailAchievementView: UIView { // MARK: - Views - let achievementView = AchievementView() +// let achievementView = AchievementView() // MARK: - Init override init(frame: CGRect) { @@ -31,10 +31,10 @@ private extension DetailAchievementView { } func setupAchievementView() { - addSubview(achievementView) - achievementView.atl - .top(equalTo: safeAreaLayoutGuide.topAnchor) - .bottom(equalTo: bottomAnchor) - .horizontal(equalTo: safeAreaLayoutGuide) +// addSubview(achievementView) +// achievementView.atl +// .top(equalTo: safeAreaLayoutGuide.topAnchor) +// .bottom(equalTo: bottomAnchor) +// .horizontal(equalTo: safeAreaLayoutGuide) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift index ac9004e3..c463e34c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift @@ -14,12 +14,12 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 13:49:16 +0900 Subject: [PATCH 160/188] =?UTF-8?q?[iOS]=20refactor:=20=EB=82=B4=EB=B9=84?= =?UTF-8?q?=EB=B0=94=20=EC=9D=B4=EB=8F=99=20(=EC=BD=94=EB=94=94=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20->=20=EB=B7=B0=EC=BB=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievementCoordinator.swift | 6 ------ .../DetailAchievementViewController.swift | 19 +++++++++++++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift index 887dbc28..b69ff73f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift @@ -24,12 +24,6 @@ public final class DetailAchievementCoordinator: Coordinator { public func start() { let detailAchievementVC = DetailAchievementViewController() detailAchievementVC.coordinator = self - - detailAchievementVC.navigationItem.rightBarButtonItems = [ - UIBarButtonItem(title: "삭제", style: .plain, target: self, action: nil), - UIBarButtonItem(title: "편집", style: .plain, target: self, action: nil) - ] - navigationController.pushViewController(detailAchievementVC, animated: true) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift index ac9004e3..96875661 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift @@ -7,6 +7,7 @@ import UIKit import Design +import Core final class DetailAchievementViewController: BaseViewController { weak var coordinator: DetailAchievementCoordinator? @@ -14,12 +15,22 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 13:49:41 +0900 Subject: [PATCH 161/188] =?UTF-8?q?[iOS]=20feat:=20achievementView=20?= =?UTF-8?q?=EC=98=A4=ED=86=A0=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Common/AchievementView.swift | 10 +++++----- .../DetailAchievement/DetailAchievementView.swift | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift index f981b488..6d7dc680 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/AchievementView.swift @@ -115,9 +115,9 @@ final class AchievementView: UIView { // MARK: - Setup extension AchievementView { private func setupUI() { - setupCategoryButton() - setupTitleTextField() setupResultImageView() + setupTitleTextField() + setupCategoryButton() setupCategoryPickerView() } @@ -125,14 +125,14 @@ extension AchievementView { addSubview(categoryButton) categoryButton.atl .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) - .top(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20) + .bottom(equalTo: titleTextField.topAnchor, constant: -5) } private func setupTitleTextField() { addSubview(titleTextField) titleTextField.atl .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) - .top(equalTo: categoryButton.bottomAnchor) + .bottom(equalTo: resultImageView.topAnchor, constant: -10) } private func setupResultImageView() { @@ -140,7 +140,7 @@ extension AchievementView { resultImageView.atl .horizontal(equalTo: safeAreaLayoutGuide) .height(equalTo: resultImageView.widthAnchor) - .top(equalTo: titleTextField.bottomAnchor, constant: 10) + .centerY(equalTo: safeAreaLayoutGuide.centerYAnchor, constant: -50) } private func setupCategoryPickerView() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index 5cf644e4..c541eed3 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -31,6 +31,8 @@ private extension DetailAchievementView { } func setupAchievementView() { + achievementView.readOnlyMode() + addSubview(achievementView) achievementView.atl .top(equalTo: safeAreaLayoutGuide.topAnchor) From 4b263b9c226724f4168c7104d87b8f6ed2df0255 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 14:28:43 +0900 Subject: [PATCH 162/188] =?UTF-8?q?[iOS]=20feat:=20DetailAchievementView?= =?UTF-8?q?=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievementViewController.swift | 25 --------- .../DetailAchievementView.swift | 55 ++++++++++++++++--- .../DetailAchievementViewController.swift | 1 - 3 files changed, 46 insertions(+), 35 deletions(-) delete mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift deleted file mode 100644 index c463e34c..00000000 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Detail/DetailAchievementViewController.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// DetailAchievementViewController.swift -// -// -// Created by Kihyun Lee on 11/22/23. -// - -import UIKit -import Design - -final class DetailAchievementViewController: BaseViewController { - weak var coordinator: DetailAchievementCoordinator? - - override func viewDidLoad() { - super.viewDidLoad() - -// layoutView.achievementView.readOnlyMode() -// layoutView.achievementView.update(image: MotiImage.sample1) -// layoutView.achievementView.categoryButton.addTarget(self, action: #selector(showPicker), for: .touchUpInside) - } - - @objc private func showPicker() { -// layoutView.achievementView.showCategoryPicker() - } -} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index 34f8a142..8025bebc 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -11,7 +11,28 @@ import Design final class DetailAchievementView: UIView { // MARK: - Views -// let achievementView = AchievementView() + private let titleLabel = { + let label = UILabel() + label.text = "달성 기록 제목" + label.font = .largeBold + return label + }() + + private let categoryLabel = { + let label = UILabel() + label.text = "카테고리 이름" + label.font = .medium + return label + }() + + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = .gray + imageView.clipsToBounds = true + return imageView + }() + // MARK: - Init override init(frame: CGRect) { @@ -26,15 +47,31 @@ final class DetailAchievementView: UIView { } private extension DetailAchievementView { - func setupUI() { - setupAchievementView() + private func setupUI() { + setupImageView() + setupTitleLabel() + setupCategoryLabel() + } + + private func setupCategoryLabel() { + addSubview(categoryLabel) + categoryLabel.atl + .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) + .bottom(equalTo: titleLabel.topAnchor, constant: -5) + } + + private func setupTitleLabel() { + addSubview(titleLabel) + titleLabel.atl + .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) + .bottom(equalTo: imageView.topAnchor, constant: -10) } - func setupAchievementView() { -// addSubview(achievementView) -// achievementView.atl -// .top(equalTo: safeAreaLayoutGuide.topAnchor) -// .bottom(equalTo: bottomAnchor) -// .horizontal(equalTo: safeAreaLayoutGuide) + private func setupImageView() { + addSubview(imageView) + imageView.atl + .horizontal(equalTo: safeAreaLayoutGuide) + .height(equalTo: imageView.widthAnchor) + .centerY(equalTo: safeAreaLayoutGuide.centerYAnchor, constant: -50) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift index 96875661..0700fbeb 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift @@ -15,7 +15,6 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 14:29:27 +0900 Subject: [PATCH 163/188] =?UTF-8?q?[iOS]=20feat:=20EditAchievement?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditAchievementCoordinator.swift | 9 ++- .../EditAchievement/EditAchievementView.swift | 2 - .../EditAchievementViewController.swift | 40 +++++++++--- .../EditAchievementViewModel.swift | 61 +++++++++++++++++++ 4 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift index a253d63f..477fda26 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift @@ -7,6 +7,7 @@ import UIKit import Core +import Data final class EditAchievementCoordinator: Coordinator { var parentCoordinator: Coordinator? @@ -26,7 +27,13 @@ final class EditAchievementCoordinator: Coordinator { } func startAfterCapture(image: UIImage) { - let editAchievementVC = EditAchievementViewController(image: image) + let editAchievementVM = EditAchievementViewModel( + fetchCategoryListUseCase: .init(repository: CategoryListRepository()) + ) + let editAchievementVC = EditAchievementViewController( + viewModel: editAchievementVM, + image: image + ) editAchievementVC.coordinator = self editAchievementVC.navigationItem.leftBarButtonItem = UIBarButtonItem( diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift index 8d63800a..9e984fae 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift @@ -5,8 +5,6 @@ // Created by 유정주 on 11/23/23. // -import UIKit - import UIKit import Design import Domain diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift index de8e8662..c803e0ee 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift @@ -8,18 +8,24 @@ import UIKit import Core import Design +import Combine final class EditAchievementViewController: BaseViewController { // MARK: - Properties weak var coordinator: EditAchievementCoordinator? - private let image: UIImage + private let viewModel: EditAchievementViewModel + private var cancellables: Set = [] - private let categories: [String] = ["카테고리1", "카테고리2", "카테고리3", "카테고리4", "카테고리5"] + private let image: UIImage private var bottomSheet = TextViewBottomSheet() // MARK: - Init - init(image: UIImage) { + init( + viewModel: EditAchievementViewModel, + image: UIImage + ) { + self.viewModel = viewModel self.image = image super.init(nibName: nil, bundle: nil) } @@ -32,12 +38,15 @@ final class EditAchievementViewController: BaseViewController Int { - return categories.count + return viewModel.categories.count } func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { - return categories[row] + return viewModel.findCategory(at: row).name } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift new file mode 100644 index 00000000..b9eefd77 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift @@ -0,0 +1,61 @@ +// +// EditAchievementViewModel.swift +// +// +// Created by 유정주 on 11/23/23. +// + +import Foundation +import Domain + +final class EditAchievementViewModel { + + enum Action { + case fetchCategories + } + + enum CategoryState { + case none + case loading + case finish + } + + private let fetchCategoryListUseCase: FetchCategoryListUseCase + private(set) var categories: [CategoryItem] = [] + + @Published private(set) var categoryState: CategoryState = .none + + init( + fetchCategoryListUseCase: FetchCategoryListUseCase + ) { + self.fetchCategoryListUseCase = fetchCategoryListUseCase + } + + func action(_ action: Action) { + switch action { + case .fetchCategories: + fetchCategories() + } + } + + func findCategory(at index: Int) -> CategoryItem { + return categories[index] + } + + private func fetchCategories() { + Task { + do { + categoryState = .loading + categories = try await fetchCategoryListUseCase.execute() + if !categories.isEmpty { + // 전체 카테고리 제거 + categories.removeFirst() + } + } catch { + categories = [] + } + + categoryState = .finish + } + } +} From bcc2bf6e184de076f123e2c1e8ccd0d4efd3a965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 16:18:28 +0900 Subject: [PATCH 164/188] =?UTF-8?q?[iOS]=20feat:=20achievement=EB=A5=BC=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=EB=B0=9B=EC=95=84=20Edit=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=9D=EC=84=B1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/CaptureCoordinator.swift | 1 + .../Common/TextViewBottomSheet.swift | 67 ++++++++++++++++++- .../EditAchievementCoordinator.swift | 21 ++++++ .../EditAchievement/EditAchievementView.swift | 34 +++++++--- .../EditAchievementViewController.swift | 34 ++++++++-- .../EditAchievementViewModel.swift | 10 +++ 6 files changed, 153 insertions(+), 14 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index 3f5edc33..8a9efd3e 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -7,6 +7,7 @@ import UIKit import Core +import Domain final class CaptureCoordinator: Coordinator { var parentCoordinator: Coordinator? diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift index 9baee596..7b74464f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine final class TextViewBottomSheet: UIViewController { @@ -15,9 +16,33 @@ final class TextViewBottomSheet: UIViewController { return textView }() + private var isPlaceHolder = true + private let placeholder = "(선택) 도전 성공한 소감이 어떠신가요?\n소감을 기록해 보세요!" + + var text: String { + return isPlaceHolder ? "" : textView.text + } + + // MARK: - Init + init(text: String? = nil) { + super.init(nibName: nil, bundle: nil) + if let text { + showText(text) + } else { + showPlaceholder() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() - + + textView.delegate = self + view.backgroundColor = textView.backgroundColor view.addSubview(textView) textView.atl @@ -25,6 +50,46 @@ final class TextViewBottomSheet: UIViewController { .bottom(equalTo: view.safeAreaLayoutGuide.bottomAnchor) .horizontal(equalTo: view.safeAreaLayoutGuide, constant: 20) } + + // MARK: - Methods + func update(body: String) { + if body.isEmpty { + showPlaceholder() + } else { + showText(body) + } + } + + private func showPlaceholder() { + isPlaceHolder = true + textView.text = placeholder + textView.textColor = .placeholderText + } + + private func hidePlaceholder() { + isPlaceHolder = false + textView.text = "" + textView.textColor = .label + } + + private func showText(_ text: String) { + hidePlaceholder() + textView.text = text + } +} + +extension TextViewBottomSheet: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + if isPlaceHolder { + hidePlaceholder() + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.isEmpty { + showPlaceholder() + } + } } extension UISheetPresentationController.Detent.Identifier { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift index 477fda26..b8ff4e22 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementCoordinator.swift @@ -8,6 +8,7 @@ import UIKit import Core import Data +import Domain final class EditAchievementCoordinator: Coordinator { var parentCoordinator: Coordinator? @@ -26,6 +27,26 @@ final class EditAchievementCoordinator: Coordinator { } + func start(achievement: Achievement) { + let editAchievementVM = EditAchievementViewModel( + fetchCategoryListUseCase: .init(repository: CategoryListRepository()) + ) + let editAchievementVC = EditAchievementViewController( + viewModel: editAchievementVM, + achievement: achievement + ) + editAchievementVC.coordinator = self + + editAchievementVC.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonAction) + ) + + navigationController.pushViewController(editAchievementVC, animated: true) + navigationController.setNavigationBarHidden(false, animated: false) + } + func startAfterCapture(image: UIImage) { let editAchievementVM = EditAchievementViewModel( fetchCategoryListUseCase: .init(repository: CategoryListRepository()) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift index 9e984fae..c80b6521 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift @@ -8,10 +8,11 @@ import UIKit import Design import Domain +import Jeongfisher final class EditAchievementView: UIView { // MARK: - Views - let resultImageView: UIImageView = { + private let resultImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.backgroundColor = .gray @@ -23,6 +24,7 @@ final class EditAchievementView: UIView { let textField = UITextField() textField.font = .largeBold textField.placeholder = "도전 성공" + textField.returnKeyType = .done return textField }() @@ -54,6 +56,7 @@ final class EditAchievementView: UIView { override init(frame: CGRect) { super.init(frame: frame) setupUI() + titleTextField.delegate = self } required init?(coder: NSCoder) { @@ -66,16 +69,19 @@ final class EditAchievementView: UIView { if let category { titleTextField.placeholder = "\(category) 도전 성공" - categoryButton.setTitle(category, for: .normal) + update(category: category) } } - func update(image: UIImage) { - resultImageView.image = image - } - - func update(title: String) { - titleTextField.text = title + func configure(achievement: Achievement) { + if let url = achievement.imageURL { + resultImageView.jf.setImage(with: url) + } + + titleTextField.text = achievement.title + if let category = achievement.category { + update(category: category) + } } func update(category: String) { @@ -83,6 +89,10 @@ final class EditAchievementView: UIView { categoryButton.setTitleColor(.label, for: .normal) } + func selectCategory(row: Int, inComponent: Int) { + categoryPickerView.selectRow(row, inComponent: inComponent, animated: false) + } + func showCategoryPicker() { categoryPickerView.isHidden = false selectDoneButton.isHidden = false @@ -138,3 +148,11 @@ extension EditAchievementView { .top(equalTo: categoryPickerView.topAnchor, constant: 10) } } + +// MARK: - UITextFieldDelegate +extension EditAchievementView: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift index c803e0ee..8306bba9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewController.swift @@ -9,6 +9,7 @@ import UIKit import Core import Design import Combine +import Domain final class EditAchievementViewController: BaseViewController { @@ -17,8 +18,9 @@ final class EditAchievementViewController: BaseViewController = [] - private let image: UIImage - private var bottomSheet = TextViewBottomSheet() + private var bottomSheet: TextViewBottomSheet + + private var achievement: Achievement? // MARK: - Init init( @@ -26,8 +28,22 @@ final class EditAchievementViewController: BaseViewController Int? { + for (index, category) in categories.enumerated() where name == category.name { + return index + } + return nil + } + private func fetchCategories() { Task { do { From f32b83732ac61d2651ed17abf76ca39e7f70d0e0 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 16:41:15 +0900 Subject: [PATCH 165/188] =?UTF-8?q?[iOS]=20feat:=20DetailAchievementDTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/DTO/DetailAchievementDTO.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift new file mode 100644 index 00000000..583a721a --- /dev/null +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift @@ -0,0 +1,44 @@ +// +// DetailAchievementDTO.swift +// +// +// Created by Kihyun Lee on 11/23/23. +// + +import Foundation +import Domain + +struct DetailAchievementResponseDTO: ResponseDataDTO { + let success: Bool? + let message: String? + let data: DetailAchievementDTO? +} + +struct DetailAchievementDTO: Codable { + let id: Int? + let title: String? + let content: String? + let imageUrl: URL? + let createdAt: Date? + let category: CategorySimpleDTO? +} + +struct CategorySimpleDTO: Codable { + let id: Int? + let name: String? + let achieveCount: Int? +} + +extension Achievement { + init(dto: DetailAchievementDTO) { + self.init( + id: dto.id ?? -1, + category: dto.category?.name ?? "", + title: dto.title ?? "", + imageURL: dto.imageUrl, + body: dto.content, + achieveCount: dto.category?.achieveCount ?? 0, + date: dto.createdAt ?? Date() + ) + } +} From 54351d8f181ec52e0e66406f52ab538a6bfdc23c Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 16:42:04 +0900 Subject: [PATCH 166/188] =?UTF-8?q?[iOS]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20cell=20=EC=84=A0=ED=83=9D=EC=8B=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 달성기록 id 넘겨주어 start 하기 --- .../Sources/Presentation/Home/HomeCoordinator.swift | 4 ++-- .../Sources/Presentation/Home/HomeViewController.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift index 5b79af38..b11773eb 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeCoordinator.swift @@ -33,9 +33,9 @@ public final class HomeCoordinator: Coordinator { navigationController.viewControllers = [homeVC] } - public func moveToDetailAchievementViewController() { + public func moveToDetailAchievementViewController(achievementId: Int) { let detailAchievementCoordinator = DetailAchievementCoordinator(navigationController, self) childCoordinators.append(detailAchievementCoordinator) - detailAchievementCoordinator.start() + detailAchievementCoordinator.start(achievementId: achievementId) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index d89fe221..e7054ae4 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -179,9 +179,9 @@ extension HomeViewController: UICollectionViewDelegate { categoryCellDidSelected(cell: cell, row: indexPath.row) } else if let cell = collectionView.cellForItem(at: indexPath) as? AchievementCollectionViewCell { // 달성 기록 리스트 셀을 눌렀을 때 - // TODO: 상세 정보 화면으로 이동 - coordinator?.moveToDetailAchievementViewController() - Logger.debug("clicked: \(viewModel.findAchievement(at: indexPath.row).title)") + // 상세 정보 화면으로 이동 + let achievementId = viewModel.findAchievement(at: indexPath.row).id + coordinator?.moveToDetailAchievementViewController(achievementId: achievementId) } } From 9ef55dde3ddd094e156a5bbbe2bc24614a1e2a68 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 16:42:33 +0900 Subject: [PATCH 167/188] =?UTF-8?q?[iOS]=20refactor:=20Achievement=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/Domain/Entity/Achievement.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index 1444f26a..db660214 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -9,19 +9,19 @@ import Foundation public struct Achievement: Hashable { public let id: Int - public let category: String? + public let category: String public let title: String public let imageURL: URL? public let body: String? - public let achieveCount: Int? - public let date: Date? + public let achieveCount: Int + public let date: Date public init( id: Int, category: String, title: String, imageURL: URL?, - body: String, + body: String?, achieveCount: Int, date: Date ) { @@ -36,11 +36,11 @@ public struct Achievement: Hashable { public init(id: Int, title: String, imageURL: URL?) { self.id = id - self.category = nil + self.category = "" self.title = title self.imageURL = imageURL self.body = nil - self.achieveCount = nil - self.date = nil + self.achieveCount = 0 + self.date = Date() } } From f1161effec7d4f61b532cc71946524fe12076e93 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 16:43:24 +0900 Subject: [PATCH 168/188] =?UTF-8?q?[iOS]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievementCoordinator.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift index b69ff73f..c5b1a475 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift @@ -7,6 +7,7 @@ import UIKit import Core +import Data public final class DetailAchievementCoordinator: Coordinator { public var parentCoordinator: Coordinator? @@ -22,7 +23,15 @@ public final class DetailAchievementCoordinator: Coordinator { } public func start() { - let detailAchievementVC = DetailAchievementViewController() + + } + + public func start(achievementId: Int) { + let detailAchievementVC = DetailAchievementViewController( + viewModel: DetailAchievementViewModel( + fetchDetailAchievementUseCase: .init(repository: DetailAchievementRepository()), + achievementId: achievementId) + ) detailAchievementVC.coordinator = self navigationController.pushViewController(detailAchievementVC, animated: true) } From 3b44935b8bd8627fa85e4487ad0c5e592b2e7bf4 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 16:44:10 +0900 Subject: [PATCH 169/188] =?UTF-8?q?[iOS]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=20UseCase,=20Repository?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Network/Endpoint/MotiAPI.swift | 7 +++++ .../DetailAchievementRepository.swift | 25 ++++++++++++++++ .../DetailAchievementRepositoryProtocol.swift | 14 +++++++++ .../FetchDetailAchievementUseCase.swift | 29 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 iOS/moti/moti/Data/Sources/Repository/DetailAchievementRepository.swift create mode 100644 iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/DetailAchievementRepositoryProtocol.swift create mode 100644 iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift index 93047fa6..7f233732 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift @@ -15,6 +15,7 @@ enum MotiAPI: EndpointProtocol { case fetchAchievementList(requestValue: FetchAchievementListRequestValue?) case fetchCategoryList case addCategory(requestValue: AddCategoryRequestValue) + case fetchDetailAchievement(requestValue: FetchDetailAchievementRequestValue) } extension MotiAPI { @@ -34,6 +35,7 @@ extension MotiAPI { case .fetchAchievementList: return "/achievements" case .fetchCategoryList: return "/categories" case .addCategory: return "/categories" + case .fetchDetailAchievement(let requestValue): return "/achievements/\(requestValue.id)" } } @@ -45,6 +47,7 @@ extension MotiAPI { case .fetchAchievementList: return .get case .fetchCategoryList: return .get case .addCategory: return .post + case .fetchDetailAchievement: return .get } } @@ -62,6 +65,8 @@ extension MotiAPI { return nil case .addCategory: return nil + case .fetchDetailAchievement: + return nil } } @@ -79,6 +84,8 @@ extension MotiAPI { return nil case .addCategory(let requestValue): return requestValue + case .fetchDetailAchievement: + return nil } } diff --git a/iOS/moti/moti/Data/Sources/Repository/DetailAchievementRepository.swift b/iOS/moti/moti/Data/Sources/Repository/DetailAchievementRepository.swift new file mode 100644 index 00000000..20a25852 --- /dev/null +++ b/iOS/moti/moti/Data/Sources/Repository/DetailAchievementRepository.swift @@ -0,0 +1,25 @@ +// +// DetailAchievementRepository.swift +// +// +// Created by Kihyun Lee on 11/23/23. +// + +import Foundation +import Domain + +public struct DetailAchievementRepository: DetailAchievementRepositoryProtocol { + private let provider: ProviderProtocol + + public init(provider: ProviderProtocol = Provider()) { + self.provider = provider + } + + public func fetchDetailAchievement(requestValue: FetchDetailAchievementRequestValue) async throws -> Achievement { + let endpoint = MotiAPI.fetchDetailAchievement(requestValue: requestValue) + let responseDTO = try await provider.request(with: endpoint, type: DetailAchievementResponseDTO.self) + + guard let detailAchievementDTO = responseDTO.data else { throw NetworkError.decode } + return Achievement(dto: detailAchievementDTO) + } +} diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/DetailAchievementRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/DetailAchievementRepositoryProtocol.swift new file mode 100644 index 00000000..7885c66d --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/DetailAchievementRepositoryProtocol.swift @@ -0,0 +1,14 @@ +// +// DetailAchievementRepositoryProtocol.swift +// +// +// Created by Kihyun Lee on 11/23/23. +// + +import Foundation + +public protocol DetailAchievementRepositoryProtocol { + func fetchDetailAchievement( + requestValue: FetchDetailAchievementRequestValue + ) async throws -> Achievement +} diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift new file mode 100644 index 00000000..48399cd5 --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift @@ -0,0 +1,29 @@ +// +// FetchDetailAchievementUseCase.swift +// +// +// Created by Kihyun Lee on 11/23/23. +// + +import Foundation + +public struct FetchDetailAchievementRequestValue: RequestValue { + public let id: Int + + public init(id: Int) { + self.id = id + } +} + +public struct FetchDetailAchievementUseCase { + private let repository: DetailAchievementRepositoryProtocol + + public init(repository: DetailAchievementRepositoryProtocol) { + self.repository = repository + } + + public func execute(requestValue: FetchDetailAchievementRequestValue) async throws -> Achievement { + return try await repository.fetchDetailAchievement(requestValue: requestValue) + } +} + From 449be9228550e83f7525af37f4d8c9dfe42543b2 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 16:45:01 +0900 Subject: [PATCH 170/188] =?UTF-8?q?[iOS]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievementView.swift | 9 +++++ .../DetailAchievementViewController.swift | 35 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index 8025bebc..a71d0e1b 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -7,6 +7,7 @@ import UIKit import Design +import Domain final class DetailAchievementView: UIView { @@ -44,6 +45,14 @@ final class DetailAchievementView: UIView { super.init(coder: coder) setupUI() } + + func configure(achievement: Achievement) { + titleLabel.text = achievement.title + categoryLabel.text = achievement.category + if let url = achievement.imageURL { + imageView.jf.setImage(with: url) + } + } } private extension DetailAchievementView { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift index 0700fbeb..616ab2d7 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift @@ -8,14 +8,30 @@ import UIKit import Design import Core +import Combine final class DetailAchievementViewController: BaseViewController { weak var coordinator: DetailAchievementCoordinator? + private let viewModel: DetailAchievementViewModel + private var cancellables: Set = [] + + init(viewModel: DetailAchievementViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() - setupUI() + + bind() + viewModel.action(.launch) } private func setupUI() { @@ -32,4 +48,21 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 16:45:19 +0900 Subject: [PATCH 171/188] =?UTF-8?q?[iOS]=20feat:=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8=20=EB=B7=B0=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievementViewModel.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewModel.swift diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewModel.swift new file mode 100644 index 00000000..d204b7a7 --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewModel.swift @@ -0,0 +1,59 @@ +// +// DetailAchievementViewModel.swift +// +// +// Created by Kihyun Lee on 11/23/23. +// + +import Foundation +import Domain +import Core + +final class DetailAchievementViewModel { + enum DetailAchievementViewModelAction { + case launch + } + + enum LaunchState { + case initial + case success + case failed(message: String) + } + + private let fetchDetailAchievementUseCase: FetchDetailAchievementUseCase + + @Published private(set) var launchState: LaunchState = .initial + + private let achievementId: Int + var achievement: Achievement? + + init( + fetchDetailAchievementUseCase: FetchDetailAchievementUseCase, + achievementId: Int + ) { + self.fetchDetailAchievementUseCase = fetchDetailAchievementUseCase + self.achievementId = achievementId + } + + func action(_ action: DetailAchievementViewModelAction) { + switch action { + case .launch: + fetchDetailAchievement() + } + } + + private func fetchDetailAchievement() { + Task { + do { + let achievement = try await fetchDetailAchievementUseCase.execute( + requestValue: FetchDetailAchievementRequestValue(id: achievementId) + ) + self.achievement = achievement + launchState = .success + } catch { + Logger.debug("detail achievement fetch error: \(error)") + launchState = .failed(message: error.localizedDescription) + } + } + } +} From 822ab0e0c0d4e877269362d422c8f14085eb1a00 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 17:10:11 +0900 Subject: [PATCH 172/188] =?UTF-8?q?[iOS]=20refactor:=20=EA=B3=B5=EB=B0=B1?= =?UTF-8?q?=20=EB=A6=B0=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/DetailAchievement/DetailAchievementView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index a71d0e1b..101128cb 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -34,7 +34,6 @@ final class DetailAchievementView: UIView { return imageView }() - // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) From e12d8b20b7bfdaa3e68b18c7d28c966f7320ace7 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 17:13:09 +0900 Subject: [PATCH 173/188] =?UTF-8?q?[iOS]=20refactor:=20Achievement=20?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EC=98=B5=EC=85=94=EB=84=90=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Sources/Domain/Entity/Achievement.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index db660214..5833b138 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -9,12 +9,12 @@ import Foundation public struct Achievement: Hashable { public let id: Int - public let category: String + public let category: String? public let title: String public let imageURL: URL? public let body: String? - public let achieveCount: Int - public let date: Date + public let achieveCount: Int? + public let date: Date? public init( id: Int, @@ -36,11 +36,11 @@ public struct Achievement: Hashable { public init(id: Int, title: String, imageURL: URL?) { self.id = id - self.category = "" + self.category = nil self.title = title self.imageURL = imageURL self.body = nil - self.achieveCount = 0 - self.date = Date() + self.achieveCount = nil + self.date = nil } } From 92981f2e96ebbc8e52a568245b574c9fc618f617 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 17:32:53 +0900 Subject: [PATCH 174/188] =?UTF-8?q?[iOS]=20refactor:=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=EC=8B=9C=20=EB=8B=AC?= =?UTF-8?q?=EC=84=B1=EA=B8=B0=EB=A1=9D=20id=EA=B0=80=20=EC=95=84=EB=8B=8C?= =?UTF-8?q?=20=EB=8B=AC=EC=84=B1=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievementCoordinator.swift | 5 +++-- .../DetailAchievement/DetailAchievementView.swift | 4 ++++ .../DetailAchievementViewController.swift | 6 +++--- .../DetailAchievement/DetailAchievementViewModel.swift | 10 ++++------ .../Sources/Presentation/Home/HomeCoordinator.swift | 5 +++-- .../Sources/Presentation/Home/HomeViewController.swift | 4 ++-- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift index c5b1a475..8372f626 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementCoordinator.swift @@ -8,6 +8,7 @@ import UIKit import Core import Data +import Domain public final class DetailAchievementCoordinator: Coordinator { public var parentCoordinator: Coordinator? @@ -26,11 +27,11 @@ public final class DetailAchievementCoordinator: Coordinator { } - public func start(achievementId: Int) { + public func start(achievement: Achievement) { let detailAchievementVC = DetailAchievementViewController( viewModel: DetailAchievementViewModel( fetchDetailAchievementUseCase: .init(repository: DetailAchievementRepository()), - achievementId: achievementId) + achievement: achievement) ) detailAchievementVC.coordinator = self navigationController.pushViewController(detailAchievementVC, animated: true) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index 101128cb..8b736191 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -52,6 +52,10 @@ final class DetailAchievementView: UIView { imageView.jf.setImage(with: url) } } + + func update(title: String) { + titleLabel.text = title + } } private extension DetailAchievementView { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift index 616ab2d7..79cef12a 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift @@ -55,10 +55,10 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 17:48:13 +0900 Subject: [PATCH 175/188] =?UTF-8?q?[iOS]=20feat:=20achievement=20private?= =?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 타이틀 및 api 불러온 달성기록을 case 연관값으로 전달 --- .../DetailAchievementViewController.swift | 9 +++++---- .../DetailAchievementViewModel.swift | 16 +++++++++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift index 79cef12a..7509698a 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift @@ -55,10 +55,11 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 18:11:18 +0900 Subject: [PATCH 176/188] =?UTF-8?q?[iOS]=20feat:=20Achievement=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=86=8D=EC=84=B1=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - String -> CategoryItem --- .../Data/Network/DTO/DetailAchievementDTO.swift | 10 +++++++--- .../Sources/Domain/Entity/Achievement.swift | 15 ++------------- .../DetailAchievement/DetailAchievementView.swift | 2 +- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift index 583a721a..83bd83f2 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift @@ -33,12 +33,16 @@ extension Achievement { init(dto: DetailAchievementDTO) { self.init( id: dto.id ?? -1, - category: dto.category?.name ?? "", + category: CategoryItem( + id: dto.category?.id ?? -1, + name: dto.category?.name ?? "", + continued: dto.category?.achieveCount ?? 0, + lastChallenged: nil + ), title: dto.title ?? "", imageURL: dto.imageUrl, body: dto.content, - achieveCount: dto.category?.achieveCount ?? 0, - date: dto.createdAt ?? Date() + date: dto.createdAt ) } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index 5833b138..c0d29f12 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -9,28 +9,18 @@ import Foundation public struct Achievement: Hashable { public let id: Int - public let category: String? + public let category: CategoryItem? public let title: String public let imageURL: URL? public let body: String? - public let achieveCount: Int? public let date: Date? - public init( - id: Int, - category: String, - title: String, - imageURL: URL?, - body: String?, - achieveCount: Int, - date: Date - ) { + public init(id: Int, category: CategoryItem?, title: String, imageURL: URL?, body: String?, date: Date?) { self.id = id self.category = category self.title = title self.imageURL = imageURL self.body = body - self.achieveCount = achieveCount self.date = date } @@ -40,7 +30,6 @@ public struct Achievement: Hashable { self.title = title self.imageURL = imageURL self.body = nil - self.achieveCount = nil self.date = nil } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index 8b736191..d9eb4cf6 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -47,7 +47,7 @@ final class DetailAchievementView: UIView { func configure(achievement: Achievement) { titleLabel.text = achievement.title - categoryLabel.text = achievement.category + categoryLabel.text = achievement.category?.name if let url = achievement.imageURL { imageView.jf.setImage(with: url) } From cee793c670c1d3b48fddd7e87aa81f4e3cd347b9 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 18:21:48 +0900 Subject: [PATCH 177/188] =?UTF-8?q?[iOS]=20refactor:=20Achievement=20init?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리, 날짜 옵셔널 제거 --- iOS/moti/moti/Application/AppCoordinator.swift | 2 +- .../Sources/Data/Network/DTO/DetailAchievementDTO.swift | 2 +- .../moti/Domain/Sources/Domain/Entity/Achievement.swift | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/iOS/moti/moti/Application/AppCoordinator.swift b/iOS/moti/moti/Application/AppCoordinator.swift index 31e25329..7880568a 100644 --- a/iOS/moti/moti/Application/AppCoordinator.swift +++ b/iOS/moti/moti/Application/AppCoordinator.swift @@ -24,7 +24,7 @@ final class AppCoordinator: Coordinator { } func start() { - moveHomeViewController() + moveLaunchViewController() } private func moveLaunchViewController() { diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift index 83bd83f2..d2aa1b04 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/DetailAchievementDTO.swift @@ -42,7 +42,7 @@ extension Achievement { title: dto.title ?? "", imageURL: dto.imageUrl, body: dto.content, - date: dto.createdAt + date: dto.createdAt ?? .now ) } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index c0d29f12..cb0f627e 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -15,7 +15,14 @@ public struct Achievement: Hashable { public let body: String? public let date: Date? - public init(id: Int, category: CategoryItem?, title: String, imageURL: URL?, body: String?, date: Date?) { + public init( + id: Int, + category: CategoryItem, + title: String, + imageURL: URL?, + body: String?, + date: Date + ) { self.id = id self.category = category self.title = title From 3013c0aa32cfba3c95e7d0c15ad12c8e0526bc84 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 18:25:56 +0900 Subject: [PATCH 178/188] =?UTF-8?q?[iOS]=20feat:=20cancel=20load=20image?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DetailAchievement/DetailAchievementView.swift | 4 ++++ .../DetailAchievement/DetailAchievementViewController.swift | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index d9eb4cf6..55e0aff6 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -56,6 +56,10 @@ final class DetailAchievementView: UIView { func update(title: String) { titleLabel.text = title } + + func cancelDownloadImage() { + imageView.jf.cancelDownloadImage() + } } private extension DetailAchievementView { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift index 7509698a..f3ac3eb5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift @@ -34,6 +34,11 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 18:27:49 +0900 Subject: [PATCH 179/188] =?UTF-8?q?[iOS]=20refactor:=20cancel=20image=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - willDisappear -> didDisappear --- .../DetailAchievement/DetailAchievementViewController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift index f3ac3eb5..a4f061c9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementViewController.swift @@ -34,8 +34,8 @@ final class DetailAchievementViewController: BaseViewController Date: Thu, 23 Nov 2023 18:35:57 +0900 Subject: [PATCH 180/188] =?UTF-8?q?[iOS]=20feat:=20Entity=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EditAchievement/EditAchievementView.swift | 2 +- .../EditAchievementViewModel.swift | 4 ++-- .../Presentation/Home/HomeViewModel.swift | 18 +++++++----------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift index c80b6521..115a1f62 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift @@ -79,7 +79,7 @@ final class EditAchievementView: UIView { } titleTextField.text = achievement.title - if let category = achievement.category { + if let category = achievement.category?.name { update(category: category) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift index 23752ad0..0e446124 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementViewModel.swift @@ -45,8 +45,8 @@ final class EditAchievementViewModel { return categories[index] } - func findCategoryIndex(_ name: String) -> Int? { - for (index, category) in categories.enumerated() where name == category.name { + func findCategoryIndex(_ item: CategoryItem) -> Int? { + for (index, category) in categories.enumerated() where item.id == category.id { return index } return nil diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 37c3f1bb..b84d6bf1 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -47,16 +47,8 @@ final class HomeViewModel { private var achievementDataSource: AchievementDataSource? private let fetchAchievementListUseCase: FetchAchievementListUseCase - private var categories: [CategoryItem] = [] { - didSet { - categoryDataSource?.update(data: categories) - } - } - private var achievements: [Achievement] = [] { - didSet { - achievementDataSource?.update(data: achievements) - } - } + private var categories: [CategoryItem] = [] + private var achievements: [Achievement] = [] private var nextRequestValue: FetchAchievementListRequestValue? private(set) var currentCategory: CategoryItem? @@ -121,8 +113,9 @@ final class HomeViewModel { let requestValue = AddCategoryRequestValue(name: name) let category = try? await addCategoryUseCase.execute(requestValue: requestValue) if let category { - addCategoryState = .finish categories.append(category) + categoryDataSource?.update(data: categories) + addCategoryState = .finish } else { addCategoryState = .error(message: "카테고리 추가에 실패했습니다.") } @@ -136,6 +129,8 @@ final class HomeViewModel { let (achievements, nextRequestValue) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) self.achievements.append(contentsOf: achievements) self.nextRequestValue = nextRequestValue + achievementDataSource?.update(data: achievements) + achievementState = .finish } catch { achievementState = .error(message: error.localizedDescription) @@ -151,6 +146,7 @@ final class HomeViewModel { currentCategory = category // 새로운 카테고리 데이터를 가져오기 때문에 빈 배열로 초기화 achievements = [] + achievementDataSource?.update(data: achievements) let requestValue = FetchAchievementListRequestValue(categoryId: category.id, take: nil, whereIdLessThan: nil) fetchAchievementList(requestValue: requestValue) From 9cc7693fa229e620067d68cc9fd2942bd6b4da93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 18:40:22 +0900 Subject: [PATCH 181/188] =?UTF-8?q?[iOS]=20feat:=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Home/HomeViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index b84d6bf1..cf39e3e9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -100,6 +100,7 @@ final class HomeViewModel { Task { do { categories = try await fetchCategoryListUseCase.execute() + categoryDataSource?.update(data: categories) categoryState = .finish } catch { categoryState = .error(message: error.localizedDescription) @@ -126,9 +127,9 @@ final class HomeViewModel { Task { do { achievementState = .loading - let (achievements, nextRequestValue) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) - self.achievements.append(contentsOf: achievements) - self.nextRequestValue = nextRequestValue + let (newAchievements, next) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) + achievements.append(contentsOf: newAchievements) + nextRequestValue = next achievementDataSource?.update(data: achievements) achievementState = .finish From 89bb7c9b3be976a4cdc0bc6e273cca42409c6f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 19:03:12 +0900 Subject: [PATCH 182/188] =?UTF-8?q?[iOS]=20feat:=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Home/HomeViewModel.swift | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index cf39e3e9..56732dd2 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -47,15 +47,25 @@ final class HomeViewModel { private var achievementDataSource: AchievementDataSource? private let fetchAchievementListUseCase: FetchAchievementListUseCase - private var categories: [CategoryItem] = [] - private var achievements: [Achievement] = [] + private var categories: [CategoryItem] = [] { + didSet { + categoryDataSource?.update(data: categories) + } + } + private var achievements: [Achievement] = [] { + didSet { + achievementDataSource?.update(data: achievements) + } + } private var nextRequestValue: FetchAchievementListRequestValue? private(set) var currentCategory: CategoryItem? + private var nextAchievementTask: Task? @Published private(set) var categoryState: CategoryState = .initial @Published private(set) var addCategoryState: AddCategoryState = .none @Published private(set) var achievementState: AchievementState = .initial + // MARK: - Init init( fetchAchievementListUseCase: FetchAchievementListUseCase, fetchCategoryListUseCase: FetchCategoryListUseCase, @@ -100,7 +110,6 @@ final class HomeViewModel { Task { do { categories = try await fetchCategoryListUseCase.execute() - categoryDataSource?.update(data: categories) categoryState = .finish } catch { categoryState = .error(message: error.localizedDescription) @@ -115,7 +124,6 @@ final class HomeViewModel { let category = try? await addCategoryUseCase.execute(requestValue: requestValue) if let category { categories.append(category) - categoryDataSource?.update(data: categories) addCategoryState = .finish } else { addCategoryState = .error(message: "카테고리 추가에 실패했습니다.") @@ -124,14 +132,12 @@ final class HomeViewModel { } private func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) { - Task { + nextAchievementTask = Task { do { achievementState = .loading let (newAchievements, next) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) achievements.append(contentsOf: newAchievements) nextRequestValue = next - achievementDataSource?.update(data: achievements) - achievementState = .finish } catch { achievementState = .error(message: error.localizedDescription) @@ -144,11 +150,13 @@ final class HomeViewModel { Logger.debug("현재 카테고리입니다.") return } + currentCategory = category // 새로운 카테고리 데이터를 가져오기 때문에 빈 배열로 초기화 achievements = [] - achievementDataSource?.update(data: achievements) + nextRequestValue = nil + nextAchievementTask?.cancel() let requestValue = FetchAchievementListRequestValue(categoryId: category.id, take: nil, whereIdLessThan: nil) fetchAchievementList(requestValue: requestValue) } From a3f547f04ed00c9dd82fd5c762e559b112fc30ae Mon Sep 17 00:00:00 2001 From: Dltmd202 Date: Thu, 23 Nov 2023 19:18:40 +0900 Subject: [PATCH 183/188] =?UTF-8?q?[BE]=20chore:=20CI=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/be.ci.yml | 53 ++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/.github/workflows/be.ci.yml b/.github/workflows/be.ci.yml index f84cdfca..e90ee2ce 100644 --- a/.github/workflows/be.ci.yml +++ b/.github/workflows/be.ci.yml @@ -2,41 +2,50 @@ name: Motimate BE CI on: push: - paths: 'BE/**' + paths: + - 'BE/**' + - '.github/**' branches: [ "develop", "main" ] pull_request: - paths: 'BE/**' + paths: + - 'BE/**' + - '.github/**' branches: [ "develop", "main" ] jobs: build: runs-on: ubuntu-latest env: - DB: "mysql" - DB_HOST: "127.0.0.1" - DB_PORT: "13306" - DB_USERNAME: "root" - DB_PASSWORD: "1234" - DB_DATABASE: "motimate_test" - DB_ENTITIES: "src/**/*.entity{.ts,.js}" - DB_LOGGING: false - SWAGGER_TITLE: "Motimate" - SWAGGER_DESCRIPTION: "The Motimate API Documents" - SWAGGER_VERSION: "0.1.0" - SWAGGER_TAG: "motimate" - APPLE_PUBLIC_KEY_URL: "https://appleid.apple.com/auth/keys" - JWT_SECRET: "!@sehyeong!@" - JWT_VALIDITY: 3600000 - REFRESH_JWT_SECRET: "!@sehyeongrefresh!@" - REFRESH_JWT_VALIDITY: 604800000 - + DB: ${{ secrets.DB }} + DB_HOST: ${{ secrets.DB_HOST }} + DB_PORT: ${{ secrets.DB_PORT }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_DATABASE: ${{ secrets.DB_DATABASE }} + DB_ENTITIES: ${{ secrets.DB_ENTITIES }} + DB_LOGGING: ${{ secrets.DB_LOGGING }} + SWAGGER_TITLE: ${{ secrets.SWAGGER_TITLE }} + SWAGGER_DESCRIPTION: ${{ secrets.SWAGGER_DESCRIPTION }} + SWAGGER_VERSION: ${{ secrets.SWAGGER_VERSION }} + SWAGGER_TAG: ${{ secrets.SWAGGER_TAG }} + APPLE_PUBLIC_KEY_URL: ${{ secrets.APPLE_PUBLIC_KEY_URL }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + JWT_VALIDITY: ${{ secrets.JWT_VALIDITY }} + REFRESH_JWT_SECRET: ${{ secrets.REFRESH_JWT_SECRET }} + REFRESH_JWT_VALIDITY: ${{ secrets.REFRESH_JWT_VALIDITY }} + LOCAL_BASEPATH: ${{ secrets.LOCAL_BASEPATH }} + NCP_ENDPOINT: ${{ secrets.BCRYPT_SALT }} + NCP_REGION: ${{ secrets.NCP_REGION }} + NCP_ACCESS_KEY_ID: ${{ secrets.NCP_ACCESS_KEY_ID }} + NCP_SECRET_ACCESS_KEY: ${{ secrets.NCP_SECRET_ACCESS_KEY }} + NCP_BUCKET_NAME: ${{ secrets.NCP_BUCKET_NAME }} services: mysql: image: mysql:8.0.34 env: - MYSQL_ROOT_PASSWORD: 1234 - MYSQL_DATABASE: "motimate_test" + MYSQL_ROOT_PASSWORD: ${{ env.DB_PASSWORD }} + MYSQL_DATABASE: ${{ env.DB_DATABASE }} ports: - 13306:3306 From 6e48d616997fad9d0938d7bc262f0652da571d4f Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 19:27:31 +0900 Subject: [PATCH 184/188] =?UTF-8?q?[iOS]=20fix:=20=ED=99=88=20=EB=8B=AC?= =?UTF-8?q?=EC=84=B1=EA=B8=B0=EB=A1=9D=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Home/HomeViewModel.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 56732dd2..53a15207 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -132,11 +132,21 @@ final class HomeViewModel { } private func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) { + nextAchievementTask?.cancel() nextAchievementTask = Task { do { achievementState = .loading let (newAchievements, next) = try await fetchAchievementListUseCase.execute(requestValue: requestValue) - achievements.append(contentsOf: newAchievements) + if let nextAchievementTask, nextAchievementTask.isCancelled { + achievementState = .finish + return + } + if requestValue?.whereIdLessThan == nil { + achievements = newAchievements + } else { + achievements.append(contentsOf: newAchievements) + } + nextRequestValue = next achievementState = .finish } catch { @@ -156,7 +166,6 @@ final class HomeViewModel { achievements = [] nextRequestValue = nil - nextAchievementTask?.cancel() let requestValue = FetchAchievementListRequestValue(categoryId: category.id, take: nil, whereIdLessThan: nil) fetchAchievementList(requestValue: requestValue) } From 5e616f236503d2adcf3ef5770950516a95b577b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 19:31:56 +0900 Subject: [PATCH 185/188] =?UTF-8?q?[iOS]=20fix:=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=A9=EC=96=B4=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Home/HomeViewModel.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 53a15207..56adf813 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -57,6 +57,7 @@ final class HomeViewModel { achievementDataSource?.update(data: achievements) } } + private var lastRequestNextValue: FetchAchievementListRequestValue? private var nextRequestValue: FetchAchievementListRequestValue? private(set) var currentCategory: CategoryItem? private var nextAchievementTask: Task? @@ -132,6 +133,13 @@ final class HomeViewModel { } private func fetchAchievementList(requestValue: FetchAchievementListRequestValue? = nil) { + if requestValue?.whereIdLessThan == nil { + // 새로운 카테고리 데이터를 가져오기 때문에 빈 배열로 초기화 + achievements = [] + nextRequestValue = nil + lastRequestNextValue = nil + } + nextAchievementTask?.cancel() nextAchievementTask = Task { do { @@ -141,6 +149,7 @@ final class HomeViewModel { achievementState = .finish return } + if requestValue?.whereIdLessThan == nil { achievements = newAchievements } else { @@ -162,20 +171,18 @@ final class HomeViewModel { } currentCategory = category - // 새로운 카테고리 데이터를 가져오기 때문에 빈 배열로 초기화 - achievements = [] - nextRequestValue = nil let requestValue = FetchAchievementListRequestValue(categoryId: category.id, take: nil, whereIdLessThan: nil) fetchAchievementList(requestValue: requestValue) } private func fetchNextAchievementList() { - guard let requestValue = nextRequestValue else { + guard let requestValue = nextRequestValue, + lastRequestNextValue?.whereIdLessThan != nextRequestValue?.whereIdLessThan else { Logger.debug("마지막 페이지입니다.") return } - + lastRequestNextValue = requestValue fetchAchievementList(requestValue: requestValue) } } From 35f8369f1545fd115d404468d0affc50ce4ee992 Mon Sep 17 00:00:00 2001 From: looloolalaa Date: Thu, 23 Nov 2023 19:50:36 +0900 Subject: [PATCH 186/188] =?UTF-8?q?[iOS]=20fix:=20=EC=B9=B4=EB=A9=94?= =?UTF-8?q?=EB=9D=BC=20=EB=91=90=20=EB=B2=88=20=EB=88=8C=EB=A6=AC=EB=8A=94?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Capture/CaptureViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index fc12b87a..7bde5093 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -38,6 +38,7 @@ final class CaptureViewController: BaseViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + layoutView.captureButton.isEnabled = true checkCameraPermissions() } @@ -161,7 +162,7 @@ extension CaptureViewController { } @objc private func didClickedShutterButton() { - + layoutView.captureButton.isEnabled = false // 사진 찍기! #if targetEnvironment(simulator) // Simulator From 466d1e8bce5389c37d32b59f7bd4e0e4036af66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8C=E1=85=A5=E1=86=BC=E1=84=8C?= =?UTF-8?q?=E1=85=AE?= Date: Thu, 23 Nov 2023 20:11:32 +0900 Subject: [PATCH 187/188] =?UTF-8?q?[iOS]=20fix:=20=EC=B5=9C=EC=B4=88=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=95=8C=20=EC=8A=A4=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=EB=A7=8C=20=EB=9C=A8=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Launch/LaunchViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift index ff3fa7d4..7f18854d 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchViewModel.swift @@ -54,6 +54,7 @@ final class LaunchViewModel { } else { Logger.debug("자동 로그인 실패") resetToken() + autoLoginState = .failed(message: "최초 로그인") } } } From 75df5f7a3615b658d5c36e6acc3ee0a1dd0d562f Mon Sep 17 00:00:00 2001 From: lsh23 Date: Thu, 23 Nov 2023 20:24:46 +0900 Subject: [PATCH 188/188] =?UTF-8?q?[BE]=20chore:=20v0.1=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20plist=20=EB=A7=81=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BE/public/index.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/BE/public/index.html b/BE/public/index.html index 1d88a8c0..2054d4b2 100644 --- a/BE/public/index.html +++ b/BE/public/index.html @@ -5,6 +5,9 @@

모티 앱 v0.0 Download

-

Last updated: 2023-11-16 23:18

+ +

모티 앱 v0.1 Download

+

Last updated: 2023-11-23 20:02

+ \ No newline at end of file