: MoyaProvider {
12 |
13 | init(
14 | endpointClosure: @escaping MoyaProvider
.EndpointClosure = MoyaProvider.defaultEndpointMapping,
15 | stubClosure: @escaping MoyaProvider
.StubClosure = MoyaProvider.neverStub, callbackQueue: DispatchQueue? = nil,
16 | plugins: [PluginType] = []
17 | ) {
18 | let session = MoyaProvider
.defaultAlamofireSession()
19 | session.sessionConfiguration.timeoutIntervalForRequest = 3
20 |
21 | super.init(
22 | endpointClosure: endpointClosure,
23 | stubClosure: stubClosure,
24 | session: session,
25 | plugins: plugins
26 | )
27 | }
28 |
29 | func daechelinRequest(
30 | target: P,
31 | instance: Model.Type,
32 | completion: @escaping(Result) -> ()
33 | ) {
34 | self.request(target) { result in
35 | switch result {
36 |
37 | case .success(let response):
38 | if (200 ..< 300).contains(response.statusCode),
39 | let data = try? JSONDecoder().decode(instance, from: response.data) {
40 | completion(.success(data))
41 | } else {
42 | completion(.failure(.statusCode(response)))
43 | }
44 | case .failure(let moyaError):
45 | completion(.failure(moyaError))
46 | }
47 | }
48 | }
49 |
50 | func daechelinSimpleRequest(
51 | target: P,
52 | completion: @escaping (Result) -> Void
53 | ) {
54 | self.request(target) { result in
55 | switch result {
56 |
57 | case .success(let response):
58 | if let data = try? response.map(Data.self) {
59 | print(String(data: data, encoding: .utf8)!)
60 | }
61 | case .failure(let moyaError):
62 | print("code: \(moyaError.errorCode)\n", moyaError.localizedDescription)
63 | }
64 |
65 | completion(result)
66 | }
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Provider/RankingProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RankingProvider.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/5/24.
6 | //
7 |
8 | import Foundation
9 | import Moya
10 | import RxSwift
11 |
12 | final class RankingProvider {
13 | static let shared = RankingProvider()
14 |
15 | private let wrapper = ProviderWrapper()
16 |
17 | func getRating(_ mealType: MealType) -> Observable> {
18 | return Observable.create { observer in
19 | self.wrapper.daechelinRequest(
20 | target: .getRanking(mealType),
21 | instance: RankingResponse.self
22 | ) { result in
23 | switch result {
24 | case .success(let data):
25 | observer.onNext(.success(data))
26 | case .failure(let error):
27 | observer.onNext(.failure(error))
28 | }
29 | }
30 | return Disposables.create()
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Provider/RatingProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingProvider.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/5/24.
6 | //
7 |
8 | import Foundation
9 | import Moya
10 | import RxSwift
11 |
12 | final class RatingProvider {
13 | static let shared = RatingProvider()
14 |
15 | private let wrapper = ProviderWrapper()
16 |
17 | func getRating(_ menuId: Int) -> Observable> {
18 | return Observable.create { observer in
19 | self.wrapper.daechelinRequest(
20 | target: .getRating(menuId),
21 | instance: [RatingResponse].self
22 | ) { result in
23 | switch result {
24 | case .success(let data):
25 | observer.onNext(.success(data))
26 | case .failure(let error):
27 | observer.onNext(.failure(error))
28 | }
29 | }
30 | return Disposables.create()
31 | }
32 | }
33 |
34 |
35 | func postRating(_ menuId: Int, _ request: RatingRequest) -> Observable> {
36 |
37 | return Observable.create { observer in
38 | self.wrapper.daechelinSimpleRequest(
39 | target: .postRating(menuId, request)
40 | ) { result in
41 | switch result {
42 | case .success(let data):
43 | observer.onNext(.success(data.data))
44 | case .failure(let error):
45 | observer.onNext(.failure(error))
46 | }
47 | }
48 | return Disposables.create()
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Request/RatingRequest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingRequest.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/4/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct RatingRequest: Codable {
11 | let score: Double
12 | let comment: String
13 | }
14 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Response/MenuDetailResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuDetailResponse.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/1/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct MenuDetailResponse: Codable {
11 | let id: Int
12 | let menu: String?
13 | let date: String
14 | let cal: String?
15 | let totalScore: Double
16 | let nutrients: String?
17 | let mealType: MealType
18 | }
19 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Response/MenuResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuResponse.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/1/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct MenuResponse: Codable {
11 | let date: String
12 | let breakfast: String?
13 | let lunch: String?
14 | let dinner: String?
15 | }
16 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Response/RankingResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RankingResponse.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/5/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct RankingResponse: Codable {
11 | let ranking: [Ranking]
12 | }
13 |
14 | struct Ranking: Codable {
15 | let id: Int
16 | let menu: String
17 | let date: String
18 | let cal: String
19 | let totalScore: Double
20 | let ranking: Int
21 | }
22 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Response/RatingResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingResponse.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/5/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct RatingResponse: Codable {
11 | let comment: String
12 | }
13 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Service/MenuService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuService.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/4/24.
6 | //
7 |
8 | import Foundation
9 | import Moya
10 |
11 | enum MenuService {
12 | case getMenu(_ date: String)
13 | case getMenuDatail(_ date: String, _ mealType: MealType)
14 | }
15 |
16 | extension MenuService: TargetType {
17 |
18 | var baseURL: URL {
19 | return URL(string: apiUrl + "/menu")!
20 | }
21 |
22 | var path: String {
23 | switch self {
24 | case .getMenu(_):
25 | return ""
26 | case .getMenuDatail(_, _):
27 | return "/detail"
28 | }
29 | }
30 |
31 | var method: Moya.Method {
32 | switch self {
33 | case .getMenu(_):
34 | return .get
35 | case .getMenuDatail(_, _):
36 | return .get
37 | }
38 | }
39 |
40 | var task: Task {
41 | switch self {
42 | case let .getMenu(date):
43 | return .requestParameters(
44 | parameters: ["date": date],
45 | encoding: URLEncoding.default
46 | )
47 | case let .getMenuDatail(date, mealType):
48 | return .requestParameters(
49 | parameters: ["date": date, "mealType": mealType],
50 | encoding: URLEncoding.default
51 | )
52 | }
53 | }
54 |
55 | var validationType: Moya.ValidationType {
56 | return .successAndRedirectCodes
57 | }
58 |
59 | var headers: [String: String]? {
60 | var headers = [String: String]()
61 | headers["Content-Type"] = "application/json"
62 | return headers
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Service/RankingService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RankingService.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/5/24.
6 | //
7 |
8 | import Foundation
9 | import Moya
10 |
11 | enum RankingService {
12 | case getRanking(_ mealType: MealType)
13 | }
14 |
15 | extension RankingService: TargetType {
16 |
17 | var baseURL: URL {
18 | return URL(string: apiUrl + "/ranking")!
19 | }
20 |
21 | var path: String {
22 | switch self {
23 | case .getRanking(_):
24 | return ""
25 | }
26 | }
27 |
28 | var method: Moya.Method {
29 | switch self {
30 | case .getRanking(_):
31 | return .get
32 | }
33 | }
34 |
35 | var task: Task {
36 | switch self {
37 | case let .getRanking(mealType):
38 | return .requestParameters(
39 | parameters: ["mealType": mealType],
40 | encoding: URLEncoding.default
41 | )
42 | }
43 | }
44 |
45 | var validationType: Moya.ValidationType {
46 | return .successAndRedirectCodes
47 | }
48 |
49 | var headers: [String: String]? {
50 | var headers = [String: String]()
51 | headers["Content-Type"] = "application/json"
52 | return headers
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Network/Service/RatingService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingService.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/5/24.
6 | //
7 |
8 | import Foundation
9 | import Moya
10 |
11 | enum RatingService {
12 | case getRating(_ menuId: Int)
13 | case postRating(_ menuId: Int, _ request: RatingRequest)
14 | }
15 |
16 | extension RatingService: TargetType {
17 |
18 | var baseURL: URL {
19 | return URL(string: apiUrl + "/rating")!
20 | }
21 |
22 | var path: String {
23 | switch self {
24 | case .getRating(let menuId):
25 | return "/\(menuId)"
26 | case .postRating(let menuId, _):
27 | return "/\(menuId)"
28 | }
29 | }
30 |
31 | var method: Moya.Method {
32 | switch self {
33 | case .getRating(_):
34 | return .get
35 | case .postRating(_, _):
36 | return .post
37 | }
38 | }
39 |
40 | var task: Task {
41 | switch self {
42 | case .getRating(_):
43 | return .requestPlain
44 | case let .postRating(_, request):
45 | let params = request.comment.isEmpty
46 | ? ["score": request.score]
47 | : ["score": request.score, "comment": request.comment]
48 | return .requestParameters(
49 | parameters: params,
50 | encoding: JSONEncoding.default
51 | )
52 | }
53 | }
54 |
55 | var validationType: Moya.ValidationType {
56 | return .successAndRedirectCodes
57 | }
58 |
59 | var headers: [String: String]? {
60 | var headers = [String: String]()
61 | headers["Content-Type"] = "application/json"
62 | return headers
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Base/BaseView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseView.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/1/24.
6 | //
7 |
8 | import UIKit
9 | import SnapKit
10 | import Then
11 |
12 | class BaseView: UIView {
13 |
14 | let bound = UIScreen.main.bounds
15 |
16 | init() {
17 | super.init(frame: .zero)
18 | setUp()
19 | addView()
20 | setLayout()
21 | }
22 |
23 | required init?(coder: NSCoder) {
24 | fatalError("init(coder:) has not been implemented")
25 | }
26 |
27 | deinit {
28 | print("\(type(of: self)): \(#function)")
29 | }
30 |
31 | func setUp() { }
32 | func addView() { }
33 | func setLayout() { }
34 | }
35 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Base/BaseViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseViewController.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 4/30/24.
6 | //
7 |
8 | import UIKit
9 | import ReactorKit
10 | import RxCocoa
11 | import SnapKit
12 | import Then
13 |
14 | class BaseVC: UIViewController {
15 |
16 | let bound = UIScreen.main.bounds
17 | var disposeBag: DisposeBag = .init()
18 |
19 | override func viewDidLoad() {
20 | super.viewDidLoad()
21 |
22 | view.backgroundColor = Color.background
23 | navigationController?.setNavigationBarHidden(true, animated: true)
24 | setUp()
25 | addView()
26 | setLayout()
27 | configureVC()
28 | configureNavigation()
29 | }
30 |
31 | init(reactor: T?) {
32 | super.init(nibName: nil, bundle: nil)
33 | self.reactor = reactor
34 | }
35 |
36 | required init?(coder: NSCoder) {
37 | fatalError("init(coder:) has not been implemented")
38 | }
39 |
40 | deinit {
41 | print("\(type(of: self)): \(#function)")
42 | }
43 |
44 | func setUp() { }
45 | func addView() { }
46 | func setLayout() { }
47 | func configureVC() { }
48 | func configureNavigation() { }
49 | func bindView(reactor: T) { }
50 | func bindAction(reactor: T) { }
51 | func bindState(reactor: T) { }
52 | }
53 |
54 | extension BaseVC: View {
55 |
56 | func bind(reactor: T) {
57 | bindView(reactor: reactor)
58 | bindAction(reactor: reactor)
59 | bindState(reactor: reactor)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Home/HomeReactor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeReactor.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 4/30/24.
6 | //
7 |
8 | import UIKit
9 | import ReactorKit
10 |
11 | final class HomeReactor: Reactor {
12 |
13 | // MARK: - Properties
14 | var initialState: State = State()
15 |
16 | // MARK: - Action
17 | enum Action {
18 | case refresh
19 | case fetchMenu
20 |
21 | // button
22 | case calendarButtonDidTap
23 | case tomorrowButtonDidTap
24 | case yesterdayButtonDidTap
25 | }
26 |
27 | // MARK: - Mutation
28 | enum Mutation {
29 | case setDate(Date)
30 | case setMenu(MenuResponse?)
31 | case setRefreshing(Bool)
32 | }
33 |
34 | // MARK: - State
35 | struct State {
36 | var date: Date = Date()
37 | var menu: MenuResponse?
38 | var isRefreshing: Bool = false
39 | }
40 | }
41 |
42 | // MARK: - Mutate
43 | extension HomeReactor {
44 |
45 | private func fetchMenu(date: Date) -> Observable {
46 | return MenuProvider.shared
47 | .getMenu(date.formattingDate(format: "yyyyMMdd"))
48 | .flatMap { result -> Observable in
49 | switch result {
50 | case .success(let data):
51 | return Observable.just(.setMenu(data))
52 | case .failure(_):
53 | return Observable.empty()
54 | }
55 | }
56 | }
57 |
58 | func mutate(action: Action) -> Observable {
59 | switch action {
60 |
61 | case .fetchMenu:
62 | return fetchMenu(date: currentState.date)
63 |
64 | case .refresh:
65 | return Observable.concat([
66 | Observable.just(Mutation.setMenu(nil)),
67 | Observable.just(Mutation.setRefreshing(true)),
68 | fetchMenu(date: currentState.date),
69 | Observable.just(Mutation.setRefreshing(false))
70 | ])
71 |
72 | case .calendarButtonDidTap:
73 | return Observable.just(Mutation.setDate(Date()))
74 | .concat(fetchMenu(date: Date()))
75 |
76 | case .tomorrowButtonDidTap:
77 | let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: currentState.date) ?? Date()
78 | return Observable.just(Mutation.setDate(tomorrow))
79 | .concat(fetchMenu(date: tomorrow))
80 |
81 | case .yesterdayButtonDidTap:
82 | let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: currentState.date) ?? Date()
83 | return Observable.just(Mutation.setDate(yesterday))
84 | .concat(fetchMenu(date: yesterday))
85 | }
86 | }
87 |
88 | // MARK: - Reduce
89 | func reduce(state: State, mutation: Mutation) -> State {
90 | var newState = state
91 | switch mutation {
92 |
93 | case .setDate(let date):
94 | newState.date = date
95 |
96 | case .setMenu(let menu):
97 | newState.menu = menu
98 |
99 | case .setRefreshing(let isRefreshing):
100 | newState.isRefreshing = isRefreshing
101 | }
102 | return newState
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Home/HomeViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeViewController.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 4/30/24.
6 | //
7 |
8 | import UIKit
9 | import RxGesture
10 |
11 | final class HomeViewController: BaseVC {
12 |
13 | // MARK: - Properties
14 | private lazy var container = UIView()
15 |
16 | /// navigation bar
17 | private lazy var navigationBarView = UIView().then {
18 | $0.backgroundColor = Color.white
19 | }
20 |
21 | private lazy var navigationBarSeparateLine = UIView().then {
22 | $0.backgroundColor = Color.lightGray
23 | }
24 |
25 | private lazy var navigationBarItemView = UIView()
26 |
27 | private lazy var logoImage = UIImageView().then {
28 | $0.image = UIImage(icon: .logo)
29 | $0.contentMode = .scaleAspectFit
30 | }
31 |
32 | private lazy var rankingButton = UIButton().then {
33 | $0.setImage(UIImage(icon: .ranking), for: .normal)
34 | $0.imageView!.contentMode = .scaleAspectFit
35 | $0.tintColor = Color.darkGray
36 | }
37 |
38 | private lazy var settingButton = UIButton().then {
39 | $0.setImage(UIImage(icon: .setting), for: .normal)
40 | $0.imageView!.contentMode = .scaleAspectFill
41 | $0.tintColor = Color.darkGray
42 | }
43 |
44 | /// scroll view
45 | private lazy var scrollView = UIScrollView().then {
46 | $0.showsVerticalScrollIndicator = false
47 | $0.alwaysBounceVertical = true
48 | $0.contentInsetAdjustmentBehavior = .always
49 | $0.clipsToBounds = false
50 | }
51 |
52 | private let refreshControl = UIRefreshControl()
53 |
54 | private lazy var fadingBottomView = FadingView(position: .bottom)
55 |
56 | /// calendar button
57 | private lazy var calendarStackView = UIStackView().then {
58 | $0.axis = .horizontal
59 | $0.spacing = 10
60 | $0.distribution = .fill
61 | }
62 |
63 | private lazy var yesterdayButton = UIButton().then {
64 | $0.setImage(UIImage(icon: .leadingArrow), for: .normal)
65 | $0.imageView?.tintColor = Color.darkGray
66 | }
67 |
68 | private lazy var calendarButton = ScaledButton(
69 | scale: 0.98, backgroundColor: Color.white
70 | ).then {
71 | $0.layer.cornerRadius = 16
72 | $0.layer.borderWidth = 1
73 | $0.layer.borderColor = Color.lightGray.cgColor
74 | }
75 |
76 | private lazy var calendarDateLabel = UILabel().then {
77 | $0.text = "2023년 12월 25일 (월)"
78 | $0.textColor = Color.darkGray
79 | $0.font = .systemFont(ofSize: 14, weight: .medium)
80 | }
81 |
82 | private lazy var tomorrowButton = UIButton().then {
83 | $0.setImage(UIImage(icon: .trailingArrow), for: .normal)
84 | $0.imageView?.tintColor = Color.darkGray
85 | }
86 |
87 | /// menu container
88 | private lazy var menuContainerStackView = UIStackView().then {
89 | $0.axis = .vertical
90 | $0.spacing = 20
91 | $0.distribution = .fill
92 | }
93 |
94 | private lazy var breakfastContainer = MenuContainer(
95 | menu: nil, type: .TYPE_BREAKFAST
96 | )
97 |
98 | private lazy var lunchContainer = MenuContainer(
99 | menu: nil, type: .TYPE_LUNCH
100 | )
101 |
102 | private lazy var dinnerContainer = MenuContainer(
103 | menu: nil, type: .TYPE_DINNER
104 | )
105 |
106 | // MARK: - LifeCycle
107 | override func viewWillAppear(_ animated: Bool) {
108 | super.viewWillAppear(true)
109 |
110 | print("\(type(of: self)): \(#function)")
111 | }
112 |
113 | // MARK: - UI
114 | override func configureVC() {
115 | scrollView.refreshControl = refreshControl
116 | }
117 |
118 | override func addView() {
119 | view.addSubview(container)
120 | container.addSubviews(
121 | scrollView, navigationBarView, fadingBottomView
122 | )
123 | navigationBarView.addSubviews(
124 | navigationBarItemView, navigationBarSeparateLine
125 | )
126 | navigationBarItemView.addSubviews(
127 | logoImage, rankingButton, settingButton
128 | )
129 | scrollView.addSubviews(
130 | calendarStackView, menuContainerStackView
131 | )
132 | calendarStackView.addArrangedSubviews(
133 | yesterdayButton, calendarButton, tomorrowButton
134 | )
135 | calendarButton.addSubview(calendarDateLabel)
136 | menuContainerStackView.addArrangedSubviews(
137 | breakfastContainer, lunchContainer, dinnerContainer
138 | )
139 | }
140 |
141 | override func setLayout() {
142 | container.snp.makeConstraints {
143 | $0.edges.equalToSuperview()
144 | }
145 | /// navigation bar
146 | navigationBarView.snp.makeConstraints {
147 | $0.top.horizontalEdges.equalToSuperview()
148 | $0.bottom.equalTo(navigationBarItemView.snp.bottom).offset(16)
149 | }
150 | navigationBarItemView.snp.makeConstraints {
151 | $0.height.equalTo(24)
152 | $0.top.equalTo(view.safeAreaLayoutGuide).inset(16)
153 | $0.horizontalEdges.equalToSuperview().inset(20)
154 | }
155 | navigationBarSeparateLine.snp.makeConstraints {
156 | $0.height.equalTo(1)
157 | $0.bottom.horizontalEdges.equalToSuperview()
158 | }
159 | logoImage.snp.makeConstraints {
160 | $0.height.equalToSuperview()
161 | $0.width.equalTo(108)
162 | $0.top.equalTo(navigationBarItemView.snp.top).offset(2)
163 | $0.leading.equalTo(navigationBarItemView.snp.leading)
164 | }
165 | rankingButton.snp.makeConstraints {
166 | $0.height.equalToSuperview()
167 | $0.trailing.equalTo(settingButton.snp.leading).offset(-10)
168 | }
169 | settingButton.snp.makeConstraints {
170 | $0.height.trailing.equalToSuperview()
171 | }
172 | /// scroll view
173 | scrollView.snp.makeConstraints {
174 | $0.top.equalTo(navigationBarView.snp.bottom).offset(34)
175 | $0.bottom.horizontalEdges.equalTo(view.safeAreaLayoutGuide)
176 | }
177 | fadingBottomView.snp.makeConstraints {
178 | $0.horizontalEdges.equalToSuperview()
179 | $0.bottom.equalTo(container.snp.bottom)
180 | $0.height.equalTo(bound.height / 12)
181 | }
182 | /// calendar button
183 | calendarStackView.snp.makeConstraints {
184 | $0.centerX.equalToSuperview()
185 | }
186 | yesterdayButton.snp.makeConstraints {
187 | $0.leading.equalToSuperview()
188 | }
189 | calendarButton.snp.makeConstraints {
190 | $0.centerX.equalToSuperview()
191 | }
192 | calendarDateLabel.snp.makeConstraints {
193 | $0.verticalEdges.equalToSuperview().inset(6)
194 | $0.horizontalEdges.equalToSuperview().inset(14)
195 | }
196 | tomorrowButton.snp.makeConstraints {
197 | $0.trailing.equalToSuperview()
198 | }
199 | /// menu container
200 | menuContainerStackView.snp.makeConstraints {
201 | $0.top.equalTo(calendarStackView.snp.bottom).offset(20)
202 | $0.width.equalTo(scrollView.snp.width).inset(16)
203 | $0.centerX.equalToSuperview()
204 | }
205 | }
206 |
207 | // MARK: - Reactor
208 | override func bindView(reactor: HomeReactor) {
209 | refreshControl.rx.controlEvent(.valueChanged)
210 | .map { .refresh }
211 | .bind(to: reactor.action)
212 | .disposed(by: disposeBag)
213 |
214 | rankingButton.rx.tap
215 | .subscribe(onNext: { [weak self] in
216 | let vc = RankingViewController(reactor: RankingReactor())
217 | self?.navigationController?.pushViewController(vc, animated: true)
218 | })
219 | .disposed(by: disposeBag)
220 |
221 | settingButton.rx.tap
222 | .subscribe(onNext: { [weak self] in
223 | let vc = SettingViewController(reactor: SettingReactor())
224 | self?.navigationController?.pushViewController(vc, animated: true)
225 | })
226 | .disposed(by: disposeBag)
227 |
228 | breakfastContainer.rx.tapGesture()
229 | .when(.recognized)
230 | .subscribe(onNext: { [weak self] _ in
231 | guard reactor.currentState.menu?.breakfast != nil else { return }
232 | let vc = MenuInfoViewController(
233 | reactor: MenuInfoReactor(
234 | date: reactor.currentState.date,
235 | type: .TYPE_BREAKFAST
236 | )
237 | )
238 | self?.navigationController?.pushViewController(vc, animated: true)
239 | })
240 | .disposed(by: disposeBag)
241 |
242 | lunchContainer.rx.tapGesture()
243 | .when(.recognized)
244 | .subscribe(onNext: { [weak self] _ in
245 | guard reactor.currentState.menu?.lunch != nil else { return }
246 | let vc = MenuInfoViewController(
247 | reactor: MenuInfoReactor(
248 | date: reactor.currentState.date,
249 | type: .TYPE_LUNCH
250 | )
251 | )
252 | self?.navigationController?.pushViewController(vc, animated: true)
253 | })
254 | .disposed(by: disposeBag)
255 |
256 | dinnerContainer.rx.tapGesture()
257 | .when(.recognized)
258 | .subscribe(onNext: { [weak self] _ in
259 | guard reactor.currentState.menu?.dinner != nil else { return }
260 | let vc = MenuInfoViewController(
261 | reactor: MenuInfoReactor(
262 | date: reactor.currentState.date,
263 | type: .TYPE_DINNER
264 | )
265 | )
266 | self?.navigationController?.pushViewController(vc, animated: true)
267 | })
268 | .disposed(by: disposeBag)
269 | }
270 |
271 | override func bindAction(reactor: HomeReactor) {
272 | reactor.action.onNext(.fetchMenu)
273 |
274 | yesterdayButton.rx.tap
275 | .map { .yesterdayButtonDidTap }
276 | .bind(to: reactor.action)
277 | .disposed(by: disposeBag)
278 |
279 | calendarButton.rx.tap
280 | .map { .calendarButtonDidTap }
281 | .bind(to: reactor.action)
282 | .disposed(by: disposeBag)
283 |
284 | tomorrowButton.rx.tap
285 | .map { .tomorrowButtonDidTap }
286 | .bind(to: reactor.action)
287 | .disposed(by: disposeBag)
288 | }
289 |
290 | override func bindState(reactor: HomeReactor) {
291 | reactor.state.map { $0.date }
292 | .distinctUntilChanged()
293 | .map { "\($0.formattingDate(format: "yyyy년 M월 d일 (E)"))" }
294 | .bind(to: calendarDateLabel.rx.text)
295 | .disposed(by: disposeBag)
296 |
297 | reactor.state.map { $0.isRefreshing }
298 | .distinctUntilChanged()
299 | .bind(to: refreshControl.rx.isRefreshing)
300 | .disposed(by: disposeBag)
301 |
302 | reactor.state.compactMap { $0.menu }
303 | .subscribe(onNext: { [weak self] menuResponse in
304 | let breakfast = menuResponse.breakfast
305 | let lunch = menuResponse.lunch
306 | let dinner = menuResponse.dinner
307 | self?.breakfastContainer.configuration(menu: breakfast)
308 | self?.lunchContainer.configuration(menu: lunch)
309 | self?.dinnerContainer.configuration(menu: dinner)
310 | })
311 | .disposed(by: disposeBag)
312 | }
313 | }
314 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Home/MenuContainer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuContainer.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import UIKit
9 |
10 | final class MenuContainer: BaseView {
11 |
12 | init(
13 | menu: String?,
14 | type: MealType
15 | ) {
16 | super.init()
17 | setUI(for: type)
18 | configuration(menu: menu)
19 | }
20 |
21 | required init?(coder: NSCoder) {
22 | fatalError("init(coder:) has not been implemented")
23 | }
24 |
25 | // MARK: - Properties
26 | private lazy var container = UIView().then {
27 | $0.backgroundColor = Color.white
28 | $0.layer.cornerRadius = 12
29 | $0.layer.shadowRadius = 2
30 | $0.layer.shadowOpacity = 0.9
31 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
32 | $0.clipsToBounds = false
33 | }
34 |
35 |
36 | private lazy var foodIcon = UIImageView().then {
37 | $0.contentMode = .scaleAspectFit
38 | }
39 |
40 | private lazy var mealView = UIView().then {
41 | $0.layer.cornerRadius = 13
42 | $0.clipsToBounds = true
43 | }
44 |
45 | private lazy var mealLabel = UILabel().then {
46 | $0.text = "meal"
47 | $0.textColor = Color.white
48 | $0.font = .systemFont(ofSize: 16, weight: .semibold)
49 | }
50 |
51 | private lazy var menuLabel = UILabel().then {
52 | $0.textColor = Color.darkGray
53 | $0.font = .systemFont(ofSize: 14, weight: .regular)
54 | $0.numberOfLines = 4
55 | }
56 |
57 | // MARK: - UI
58 | func configuration(menu: String?) {
59 | self.menuLabel.text = menu ?? "급식이 없어요."
60 | }
61 |
62 | override func addView() {
63 | self.addSubview(container)
64 | container.addSubviews(foodIcon, mealView, menuLabel)
65 | mealView.addSubview(mealLabel)
66 | }
67 |
68 | override func setLayout() {
69 | container.snp.makeConstraints {
70 | $0.height.equalTo(120)
71 | $0.top.equalToSuperview()
72 | $0.bottom.equalToSuperview()
73 | $0.horizontalEdges.equalToSuperview()
74 | }
75 | foodIcon.snp.makeConstraints {
76 | $0.leading.equalToSuperview().offset(20)
77 | $0.top.equalToSuperview().offset(10)
78 | }
79 | mealView.snp.makeConstraints {
80 | $0.leading.equalToSuperview().offset(20)
81 | $0.top.equalTo(foodIcon.snp.bottom)
82 | $0.width.equalTo(66)
83 | $0.bottom.equalToSuperview().offset(-16)
84 | }
85 | mealLabel.snp.makeConstraints {
86 | $0.verticalEdges.equalToSuperview().inset(4)
87 | $0.centerX.equalToSuperview()
88 | }
89 | menuLabel.snp.makeConstraints {
90 | $0.leading.equalTo(mealView.snp.trailing).offset(20)
91 | $0.trailing.equalToSuperview().offset(-20)
92 | $0.centerY.equalToSuperview()
93 | }
94 | }
95 |
96 | private func setUI(for type: MealType) {
97 | self.container.layer.shadowColor = Color.getMealColor(for: type).cgColor
98 | self.mealView.backgroundColor = Color.getMealColor(for: type)
99 |
100 | switch type {
101 | case .TYPE_BREAKFAST:
102 | self.foodIcon.image = UIImage(icon: .taco)
103 | self.mealLabel.text = "조식"
104 | case .TYPE_LUNCH:
105 | self.foodIcon.image = UIImage(icon: .burger)
106 | self.mealLabel.text = "중식"
107 | case .TYPE_DINNER:
108 | self.foodIcon.image = UIImage(icon: .ramen)
109 | self.mealLabel.text = "석식"
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/MenuInfo/CommentCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CommentCell.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/4/24.
6 | //
7 |
8 | import UIKit
9 | import Then
10 | import SnapKit
11 |
12 | class CommentCell: UITableViewCell {
13 |
14 | // MARK: - Properties
15 | static let reuseIdentifier = "CommentCell"
16 |
17 | var anonymousProfileImageArray: [UIImage] = [.cat, .rabbit, .snake, .elephant, .tiger]
18 |
19 | private lazy var anonymousProfileImageView = UIImageView().then {
20 | $0.image = anonymousProfileImageArray.randomElement()
21 | $0.contentMode = .scaleAspectFit
22 | }
23 |
24 | private lazy var commentStackView = UIStackView().then {
25 | $0.axis = .vertical
26 | $0.spacing = 2
27 | $0.distribution = .fill
28 | }
29 |
30 | private lazy var anonymousUserName = UILabel().then {
31 | $0.text = "익명의 대소고인"
32 | $0.textColor = Color.black
33 | $0.font = .systemFont(ofSize: 14, weight: .medium)
34 | }
35 |
36 | private lazy var comment = UILabel().then {
37 | $0.text = "댓글댓글 댓글댓글 댓글댓글"
38 | $0.textColor = Color.black
39 | $0.font = .systemFont(ofSize: 14, weight: .light)
40 | $0.numberOfLines = 0
41 | }
42 |
43 | // MARK: - Initialization
44 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
45 | super.init(style: style, reuseIdentifier: reuseIdentifier)
46 |
47 | contentView.backgroundColor = Color.background
48 | addView()
49 | setLayout()
50 | }
51 |
52 | required init?(coder: NSCoder) {
53 | fatalError("init(coder:) has not been implemented")
54 | }
55 |
56 | // MARK: - UI
57 | public func configuration(_ comment: RatingResponse) {
58 | self.comment.text = comment.comment
59 | }
60 |
61 | func addView() {
62 | contentView.addSubviews(
63 | anonymousProfileImageView, commentStackView
64 | )
65 | commentStackView.addArrangedSubviews(
66 | anonymousUserName, comment
67 | )
68 | }
69 |
70 | func setLayout() {
71 | contentView.snp.makeConstraints {
72 | $0.height.equalTo(70)
73 | $0.width.equalToSuperview()
74 | }
75 | anonymousProfileImageView.snp.makeConstraints {
76 | $0.width.height.equalTo(46)
77 | $0.leading.equalToSuperview().offset(4)
78 | $0.centerY.equalToSuperview()
79 | }
80 | commentStackView.snp.makeConstraints {
81 | $0.leading.equalTo(anonymousProfileImageView.snp.trailing).offset(10)
82 | $0.trailing.equalToSuperview().offset(4)
83 | $0.centerY.equalToSuperview()
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/MenuInfo/MenuInfoReactor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuInfoReactor.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import Foundation
9 | import ReactorKit
10 |
11 | final class MenuInfoReactor: Reactor {
12 |
13 | // MARK: - Properties
14 | var initialState: State
15 |
16 | // MARK: - Action
17 | enum Action {
18 | case refresh
19 | case fetchMenuDetail
20 | case fetchComments
21 | }
22 |
23 | // MARK: - Mutation
24 | enum Mutation {
25 | case setMenuDetail(MenuDetailResponse?)
26 | case setComments([RatingResponse]?)
27 | case setIsFetching
28 | }
29 |
30 | // MARK: - State
31 | struct State {
32 | var date: Date
33 | var type: MealType
34 | var menuDetail: MenuDetailResponse?
35 | var comments: [RatingResponse]?
36 | var isFetching: Bool = false
37 | }
38 |
39 | init(date: Date, type: MealType) {
40 | self.initialState = State(date: date, type: type)
41 | }
42 | }
43 |
44 | // MARK: - Mutate
45 | extension MenuInfoReactor {
46 |
47 | private func fetchMenuDetail() -> Observable {
48 | MenuProvider.shared
49 | .getMenuDetail(
50 | currentState.date.formattingDate(format: "yyyyMMdd"),
51 | currentState.type
52 | )
53 | .flatMap { result -> Observable in
54 | switch result {
55 | case .success(let data):
56 | return Observable.just(.setMenuDetail(data))
57 | case .failure(_):
58 | return Observable.just(.setMenuDetail(nil))
59 | }
60 | }
61 | }
62 |
63 | private func fetchComments() -> Observable {
64 | guard let id = currentState.menuDetail?.id else { return .empty() }
65 | return RatingProvider.shared.getRating(id)
66 | .flatMap { result -> Observable in
67 | switch result {
68 | case .success(let data):
69 | return Observable.just(.setComments(
70 | data.reversed()
71 | .filter { !($0.comment.isEmpty) }
72 | ))
73 | case .failure(_):
74 | return Observable.just(.setComments([]))
75 | }
76 | }
77 | }
78 |
79 | func mutate(action: Action) -> Observable {
80 | switch action {
81 |
82 | case .refresh:
83 | return Observable.concat([
84 | fetchMenuDetail(),
85 | Observable.just(Mutation.setComments(nil))
86 | ])
87 | .flatMap { _ in
88 | Observable.concat([
89 | Observable.just(Mutation.setComments(nil)),
90 | Observable.just(Mutation.setIsFetching),
91 | self.fetchComments()
92 | ])
93 | }
94 |
95 | case .fetchMenuDetail:
96 | return fetchMenuDetail()
97 |
98 | case .fetchComments:
99 | return fetchMenuDetail().flatMap { _ in
100 | self.fetchComments()
101 | }
102 | }
103 | }
104 |
105 | // MARK: - Reduce
106 | func reduce(state: State, mutation: Mutation) -> State {
107 | var newState = state
108 | switch mutation {
109 |
110 | case .setMenuDetail(let menuDetail):
111 | newState.menuDetail = menuDetail
112 | newState.isFetching = false
113 |
114 | case .setComments(let comments):
115 | newState.comments = comments
116 | newState.isFetching = false
117 |
118 | case .setIsFetching:
119 | newState.isFetching = true
120 | }
121 | return newState
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/MenuInfo/MenuInfoViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MenuInfoViewController.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import UIKit
9 | import Cosmos
10 |
11 | final class MenuInfoViewController: BaseVC {
12 |
13 | // MARK: - Properties
14 | private lazy var container = UIView()
15 |
16 | /// navigation bar
17 | private lazy var navigationBarView = UIView().then {
18 | $0.backgroundColor = Color.white
19 | }
20 |
21 | private lazy var navigationBarSeparateLine = UIView().then {
22 | $0.backgroundColor = Color.lightGray
23 | }
24 |
25 | private lazy var navigationBarItemView = UIView()
26 |
27 | private lazy var backButton = UIButton().then {
28 | $0.setImage(UIImage(icon: .leadingArrow), for: .normal)
29 | $0.imageView!.contentMode = .scaleAspectFit
30 | $0.tintColor = Color.black
31 | }
32 |
33 | private lazy var navigationTitle = UILabel().then {
34 | $0.text = "급식 상세 정보"
35 | $0.font = .systemFont(ofSize: 18, weight: .medium)
36 | $0.textColor = Color.black
37 | }
38 |
39 | /// scroll view
40 | private lazy var scrollView = UIScrollView().then {
41 | $0.showsVerticalScrollIndicator = false
42 | $0.alwaysBounceVertical = true
43 | $0.contentInsetAdjustmentBehavior = .always
44 | $0.clipsToBounds = false
45 | $0.delegate = self
46 | }
47 |
48 | private let refreshControl = UIRefreshControl()
49 |
50 | private lazy var scrollStackView = UIStackView().then {
51 | $0.axis = .vertical
52 | $0.spacing = 20
53 | $0.distribution = .fill
54 | }
55 |
56 | private lazy var fadingBottomView = FadingView(position: .bottom)
57 |
58 | /// menu info container
59 | private lazy var menuInfoContainer = UIView().then {
60 | $0.backgroundColor = Color.white
61 | $0.layer.cornerRadius = 12
62 | $0.layer.cornerRadius = 12
63 | $0.layer.shadowRadius = 2
64 | $0.layer.shadowOpacity = 0.9
65 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
66 | $0.clipsToBounds = false
67 | }
68 |
69 | private lazy var menuDateLabel = UILabel().then {
70 | $0.text = "2월 6일 (월)"
71 | $0.textColor = Color.darkGray
72 | $0.font = .systemFont(ofSize: 18, weight: .medium)
73 | }
74 |
75 | private lazy var mealView = UIView().then {
76 | $0.layer.cornerRadius = 14
77 | $0.clipsToBounds = true
78 | }
79 |
80 | private lazy var mealLabel = UILabel().then {
81 | $0.text = "meal"
82 | $0.textColor = Color.white
83 | $0.font = .systemFont(ofSize: 16, weight: .semibold)
84 | }
85 |
86 | private lazy var starView = CosmosView().then {
87 | $0.settings.fillMode = .half
88 | $0.settings.updateOnTouch = false
89 | $0.settings.starSize = 30
90 | $0.settings.starMargin = 4
91 | $0.settings.filledImage = UIImage(icon: .filledStar)
92 | $0.settings.emptyImage = UIImage(icon: .emptyStar)
93 | }
94 |
95 | private lazy var topSeparateLine = UIView()
96 |
97 | private lazy var menuLabel = UILabel().then {
98 | $0.text = "menu"
99 | $0.textColor = Color.darkGray
100 | $0.font = .systemFont(ofSize: 16, weight: .regular)
101 | $0.numberOfLines = 0
102 | $0.setLineSpacing(lineSpacing: 2, alignment: .center)
103 | }
104 |
105 | private lazy var bottomSeparateLine = UIView()
106 |
107 | private lazy var kcalLabel = UILabel().then {
108 | $0.text = "kcal"
109 | $0.textColor = Color.gray
110 | $0.font = .systemFont(ofSize: 16, weight: .regular)
111 | $0.numberOfLines = 0
112 | }
113 |
114 | private lazy var nutrientsLabel = UILabel().then {
115 | $0.text = "nutrients"
116 | $0.textColor = Color.darkGray
117 | $0.font = .systemFont(ofSize: 16, weight: .regular)
118 | $0.numberOfLines = 0
119 | $0.setLineSpacing(lineSpacing: 2, alignment: .center)
120 | }
121 |
122 | ///fixed menu info container
123 | private lazy var fixedMenuInfoContainer = UIView().then {
124 | $0.backgroundColor = Color.white
125 | $0.layer.shadowColor = menuInfoContainer.layer.shadowColor
126 | $0.layer.cornerRadius = 12
127 | $0.layer.shadowRadius = 2
128 | $0.layer.shadowOpacity = 0.9
129 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
130 | $0.layer.maskedCorners = CACornerMask(
131 | arrayLiteral: .layerMinXMaxYCorner, .layerMaxXMaxYCorner
132 | )
133 | $0.clipsToBounds = false
134 | $0.isUserInteractionEnabled = false
135 | }
136 |
137 | private lazy var bottomShadow = UIView().then { bottomShadow in
138 | let gradientLayer = CAGradientLayer().then {
139 | $0.frame = CGRect(x: 0, y: 0, width: view.frame.width - 32, height: 24)
140 | $0.colors = [Color.darkGray.withAlphaComponent(0.3).cgColor,
141 | Color.darkGray.withAlphaComponent(0).cgColor]
142 | $0.startPoint = CGPoint(x: 0.5, y: 0)
143 | $0.endPoint = CGPoint(x: 0.5, y: 1)
144 | $0.cornerRadius = 12
145 | $0.maskedCorners = CACornerMask(
146 | arrayLiteral: .layerMinXMaxYCorner, .layerMaxXMaxYCorner
147 | )
148 | }
149 | bottomShadow.layer.addSublayer(gradientLayer)
150 | }
151 |
152 | private lazy var fixedMenuLabel = UILabel().then {
153 | $0.textColor = Color.darkGray
154 | $0.font = .systemFont(ofSize: 16, weight: .regular)
155 | $0.numberOfLines = 0
156 | }
157 |
158 | private lazy var fixedBottomSeparateLine = UIView().then {
159 | $0.backgroundColor = bottomSeparateLine.backgroundColor
160 | }
161 |
162 | private lazy var fixedKcalLabel = UILabel().then {
163 | $0.textColor = Color.gray
164 | $0.font = .systemFont(ofSize: 16, weight: .regular)
165 | $0.numberOfLines = 0
166 | }
167 |
168 | private lazy var fixedNutrientsLabel = UILabel().then {
169 | $0.textColor = Color.darkGray
170 | $0.font = .systemFont(ofSize: 16, weight: .regular)
171 | $0.numberOfLines = 0
172 | }
173 |
174 | /// review button
175 | private lazy var reviewButton = ScaledButton(scale: 0.94).then {
176 | $0.backgroundColor = Color.white
177 | $0.layer.cornerRadius = 32
178 | $0.layer.shadowRadius = 2
179 | $0.layer.shadowOpacity = 0.9
180 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
181 | }
182 |
183 | private lazy var reviewButtonImage = UIImageView().then {
184 | $0.image = UIImage(icon: .review)
185 | $0.contentMode = .scaleAspectFit
186 | $0.tintColor = Color.black
187 | }
188 |
189 | /// comment
190 | private lazy var commentTableView = UITableView().then {
191 | $0.backgroundColor = Color.background
192 | $0.register(
193 | CommentCell.self,
194 | forCellReuseIdentifier: CommentCell.reuseIdentifier
195 | )
196 | $0.isScrollEnabled = false
197 | $0.allowsSelection = false
198 | $0.separatorStyle = .none
199 | }
200 |
201 | private lazy var emptyCommentsLabel = UILabel().then {
202 | $0.text = "아직 리뷰가 하나도 없어요"
203 | $0.textColor = Color.darkGray
204 | $0.font = .systemFont(ofSize: 16, weight: .medium)
205 | $0.textAlignment = .center
206 | }
207 |
208 | private lazy var emptyCommentsSubLabel = UILabel().then {
209 | $0.text = "직접 리뷰를 작성해 보는 건 어떠신가요??"
210 | $0.textColor = Color.darkGray
211 | $0.font = .systemFont(ofSize: 14, weight: .light)
212 | $0.textAlignment = .center
213 | }
214 |
215 | // MARK: - LifeCycle
216 | override func viewWillAppear(_ animated: Bool) {
217 | super.viewWillAppear(true)
218 |
219 | print("\(type(of: self)): \(#function)")
220 | reactor?.action.onNext(.fetchMenuDetail)
221 | reactor?.action.onNext(.fetchComments)
222 | }
223 |
224 | // MARK: - UI
225 | override func setUp() {
226 | setUIColor()
227 | }
228 |
229 | override func configureVC() {
230 | scrollView.refreshControl = refreshControl
231 | }
232 |
233 | override func addView() {
234 | view.addSubview(container)
235 | /// navigation bar
236 | container.addSubviews(
237 | scrollView, bottomShadow, fixedMenuInfoContainer,
238 | navigationBarView, fadingBottomView, reviewButton
239 | )
240 | navigationBarView.addSubviews(
241 | navigationBarItemView, navigationBarSeparateLine
242 | )
243 | navigationBarItemView.addSubviews(
244 | backButton, navigationTitle
245 | )
246 | /// scroll view
247 | scrollView.addSubview(scrollStackView)
248 | scrollStackView.addArrangedSubviews(
249 | menuInfoContainer, emptyCommentsLabel,
250 | emptyCommentsSubLabel, commentTableView
251 | )
252 | menuInfoContainer.addSubviews(
253 | menuDateLabel, mealView, starView,
254 | topSeparateLine, menuLabel, bottomSeparateLine,
255 | kcalLabel, nutrientsLabel
256 | )
257 | fixedMenuInfoContainer.addSubviews(
258 | fixedMenuLabel, fixedBottomSeparateLine,
259 | fixedKcalLabel, fixedNutrientsLabel
260 | )
261 | reviewButton.addSubview(reviewButtonImage)
262 | mealView.addSubview(mealLabel)
263 | }
264 |
265 | override func setLayout() {
266 | container.snp.makeConstraints {
267 | $0.edges.equalToSuperview()
268 | }
269 | /// navigation bar
270 | navigationBarView.snp.makeConstraints {
271 | $0.top.horizontalEdges.equalToSuperview()
272 | $0.bottom.equalTo(navigationBarItemView.snp.bottom).offset(16)
273 | }
274 | navigationBarItemView.snp.makeConstraints {
275 | $0.height.equalTo(24)
276 | $0.top.equalTo(view.safeAreaLayoutGuide).inset(16)
277 | $0.horizontalEdges.equalToSuperview().inset(16)
278 | }
279 | navigationBarSeparateLine.snp.makeConstraints {
280 | $0.height.equalTo(1)
281 | $0.bottom.horizontalEdges.equalToSuperview()
282 | }
283 | backButton.snp.makeConstraints {
284 | $0.height.leading.equalToSuperview()
285 | }
286 | navigationTitle.snp.makeConstraints {
287 | $0.height.equalToSuperview()
288 | $0.leading.equalTo(backButton.snp.trailing).offset(10)
289 | }
290 | /// scroll view
291 | scrollView.snp.makeConstraints {
292 | $0.top.equalTo(navigationBarView.snp.bottom).offset(20)
293 | $0.horizontalEdges.bottom.equalTo(view.safeAreaLayoutGuide)
294 | }
295 | scrollStackView.snp.makeConstraints {
296 | $0.verticalEdges.equalToSuperview()
297 | $0.horizontalEdges.equalToSuperview().inset(16)
298 | }
299 | fadingBottomView.snp.makeConstraints {
300 | $0.horizontalEdges.equalToSuperview()
301 | $0.bottom.equalTo(container.snp.bottom)
302 | $0.height.equalTo(bound.height / 12)
303 | }
304 | /// menu info container
305 | menuInfoContainer.snp.makeConstraints {
306 | $0.top.equalToSuperview()
307 | $0.bottom.equalTo(nutrientsLabel.snp.bottom).offset(25)
308 | $0.width.equalToSuperview()
309 | $0.centerX.equalToSuperview()
310 | }
311 | menuDateLabel.snp.makeConstraints {
312 | $0.top.equalToSuperview().offset(25)
313 | $0.bottom.equalTo(menuDateLabel.snp.top).offset(18)
314 | $0.centerX.equalToSuperview()
315 | }
316 | mealView.snp.makeConstraints {
317 | $0.width.equalTo(66)
318 | $0.top.equalTo(menuDateLabel.snp.bottom).offset(10)
319 | $0.centerX.equalToSuperview()
320 | }
321 | mealLabel.snp.makeConstraints {
322 | $0.centerX.centerY.equalToSuperview()
323 | $0.verticalEdges.equalToSuperview().inset(4)
324 | }
325 | starView.snp.makeConstraints {
326 | $0.top.equalTo(mealView.snp.bottom).offset(10)
327 | $0.centerX.equalToSuperview()
328 | }
329 | topSeparateLine.snp.makeConstraints {
330 | $0.width.equalTo(menuInfoContainer.snp.width).dividedBy(2)
331 | $0.top.equalTo(starView.snp.bottom).offset(15)
332 | $0.bottom.equalTo(topSeparateLine.snp.top).offset(1)
333 | $0.centerX.equalToSuperview()
334 | }
335 | menuLabel.snp.makeConstraints {
336 | $0.top.equalTo(topSeparateLine.snp.bottom).offset(20)
337 | $0.centerX.equalToSuperview()
338 | }
339 | bottomSeparateLine.snp.makeConstraints {
340 | $0.width.equalTo(menuInfoContainer.snp.width).dividedBy(2)
341 | $0.top.equalTo(menuLabel.snp.bottom).offset(20)
342 | $0.bottom.equalTo(bottomSeparateLine.snp.top).offset(1)
343 | $0.centerX.equalToSuperview()
344 | }
345 | kcalLabel.snp.makeConstraints {
346 | $0.top.equalTo(bottomSeparateLine.snp.bottom).offset(15)
347 | $0.centerX.equalToSuperview()
348 | }
349 | nutrientsLabel.snp.makeConstraints {
350 | $0.top.equalTo(kcalLabel.snp.bottom).offset(4)
351 | $0.centerX.equalToSuperview()
352 | }
353 | /// fixed menu info container
354 | fixedMenuInfoContainer.snp.makeConstraints {
355 | $0.top.equalTo(navigationBarView.snp.bottom)
356 | $0.bottom.equalTo(fixedNutrientsLabel.snp.bottom).offset(25)
357 | $0.width.equalTo(scrollView.snp.width).inset(16)
358 | $0.centerX.equalToSuperview()
359 | }
360 | fixedMenuLabel.snp.makeConstraints {
361 | $0.top.equalTo(fixedMenuInfoContainer.snp.top).offset(20)
362 | $0.centerX.equalToSuperview()
363 | }
364 | fixedBottomSeparateLine.snp.makeConstraints {
365 | $0.width.equalTo(fixedMenuInfoContainer.snp.width).dividedBy(2)
366 | $0.top.equalTo(fixedMenuLabel.snp.bottom).offset(20)
367 | $0.bottom.equalTo(fixedBottomSeparateLine.snp.top).offset(1)
368 | $0.centerX.equalToSuperview()
369 | }
370 | fixedKcalLabel.snp.makeConstraints {
371 | $0.top.equalTo(fixedBottomSeparateLine.snp.bottom).offset(15)
372 | $0.centerX.equalToSuperview()
373 | }
374 | fixedNutrientsLabel.snp.makeConstraints {
375 | $0.top.equalTo(fixedKcalLabel.snp.bottom).offset(4)
376 | $0.centerX.equalToSuperview()
377 | }
378 | bottomShadow.snp.makeConstraints {
379 | $0.top.equalTo(fixedMenuInfoContainer.snp.bottom).offset(-12)
380 | $0.leading.equalTo(fixedMenuInfoContainer.snp.leading)
381 | $0.centerX.equalToSuperview()
382 | }
383 | /// review button
384 | reviewButton.snp.makeConstraints {
385 | $0.width.height.equalTo(64)
386 | $0.trailing.equalTo(view.safeAreaLayoutGuide).inset(16)
387 | $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(20)
388 | }
389 | reviewButtonImage.snp.makeConstraints {
390 | $0.width.height.equalTo(30)
391 | $0.centerY.centerX.equalToSuperview()
392 | }
393 | /// comment
394 | commentTableView.snp.makeConstraints {
395 | $0.width.equalTo(scrollView.snp.width).inset(16)
396 | $0.height.equalTo(100)
397 | $0.centerX.equalToSuperview()
398 | }
399 | emptyCommentsLabel.snp.makeConstraints {
400 | $0.centerX.equalToSuperview()
401 | }
402 | emptyCommentsSubLabel.snp.makeConstraints {
403 | $0.top.equalTo(emptyCommentsLabel.snp.bottom).offset(4)
404 | $0.centerX.equalToSuperview()
405 | }
406 | }
407 |
408 | private func setUIColor() {
409 | guard let type = reactor?.currentState.type else { return }
410 | let color = Color.getMealColor(for: reactor!.currentState.type)
411 |
412 | [menuInfoContainer, reviewButton].forEach {
413 | $0.layer.shadowColor = color.cgColor
414 | }
415 | [bottomSeparateLine, topSeparateLine].forEach {
416 | $0.backgroundColor = color.withAlphaComponent(0.7)
417 | }
418 | mealView.backgroundColor = color
419 | mealLabel.text = {
420 | switch type {
421 | case .TYPE_BREAKFAST: return "조식"
422 | case .TYPE_LUNCH: return "중식"
423 | case .TYPE_DINNER: return "석식"
424 | }
425 | }()
426 | }
427 |
428 | // MARK: - Reactor
429 | override func bindView(reactor: MenuInfoReactor) {
430 | refreshControl.rx.controlEvent(.valueChanged)
431 | .map { .refresh }
432 | .bind(to: reactor.action)
433 | .disposed(by: disposeBag)
434 |
435 | backButton.rx.tap
436 | .subscribe(onNext: { [weak self] in
437 | self?.navigationController?.popViewController(animated: true)
438 | })
439 | .disposed(by: disposeBag)
440 |
441 | reviewButton.rx.tap
442 | .subscribe(onNext: { [weak self] in
443 | let menuId = reactor.currentState.menuDetail?.id ?? 0
444 | let vc = ReviewViewController(reactor: ReviewReactor(menuId: menuId))
445 | self?.navigationController?.pushViewController(vc, animated: true)
446 | })
447 | .disposed(by: disposeBag)
448 | }
449 |
450 | override func bindAction(reactor: MenuInfoReactor) {
451 | reactor.action.onNext(.fetchMenuDetail)
452 | reactor.action.onNext(.fetchComments)
453 | }
454 |
455 | override func bindState(reactor: MenuInfoReactor) {
456 | reactor.state.map { $0.date }
457 | .distinctUntilChanged()
458 | .map { "\($0.formattingDate(format: "M월 d일 (E)"))" }
459 | .bind(to: menuDateLabel.rx.text)
460 | .disposed(by: disposeBag)
461 |
462 | reactor.state.map { $0.isFetching }
463 | .distinctUntilChanged()
464 | .bind(to: refreshControl.rx.isRefreshing)
465 | .disposed(by: disposeBag)
466 |
467 | reactor.state.compactMap { $0.menuDetail }
468 | .map { $0.totalScore }
469 | .bind(to: starView.rx.rating)
470 | .disposed(by: disposeBag)
471 |
472 | reactor.state.compactMap { $0.menuDetail }
473 | .map { $0.menu }
474 | .subscribe(onNext: { [weak self] menu in
475 | let menu = menu?.replacingOccurrences(of: " ", with: "\n")
476 | self?.menuLabel.text = menu
477 | self?.fixedMenuLabel.text = menu
478 | self?.fixedMenuLabel.setLineSpacing(lineSpacing: 2, alignment: .center)
479 | })
480 | .disposed(by: disposeBag)
481 |
482 | reactor.state.compactMap { $0.menuDetail }
483 | .map { $0.cal }
484 | .subscribe(onNext: { [weak self] cal in
485 | self?.kcalLabel.text = cal
486 | self?.fixedKcalLabel.text = cal
487 | })
488 | .disposed(by: disposeBag)
489 |
490 | reactor.state.compactMap { $0.menuDetail }
491 | .map { $0.nutrients }
492 | .subscribe(onNext: { [weak self] nutrients in
493 | let nutrientsArray = nutrients?.components(separatedBy: ", ")[0...2]
494 | let replacingNutrients = nutrientsArray?.map {
495 | $0.replacingOccurrences(of: "(g)", with: "") + "g"
496 | }.joined(separator: "\n")
497 | self?.nutrientsLabel.text = replacingNutrients
498 | self?.fixedNutrientsLabel.text = replacingNutrients
499 | self?.fixedNutrientsLabel.setLineSpacing(lineSpacing: 2, alignment: .center)
500 | })
501 | .disposed(by: disposeBag)
502 |
503 | reactor.state.compactMap { $0.comments }
504 | .bind(to: commentTableView.rx.items(
505 | cellIdentifier: CommentCell.reuseIdentifier,
506 | cellType: CommentCell.self)
507 | ) { _, comment, cell in
508 | cell.configuration(comment)
509 | }
510 | .disposed(by: disposeBag)
511 |
512 | reactor.state.compactMap { $0.comments }
513 | .filter { !$0.isEmpty }
514 | .subscribe(onNext: { [weak self] comments in
515 | self?.emptyCommentsLabel.removeFromSuperview()
516 | self?.emptyCommentsSubLabel.removeFromSuperview()
517 |
518 | let commentsCount = comments.count
519 | self?.commentTableView.snp.updateConstraints {
520 | $0.height.equalTo((commentsCount * 70) + 100)
521 | }
522 | self?.commentTableView.layoutIfNeeded()
523 | })
524 | .disposed(by: disposeBag)
525 | }
526 | }
527 |
528 | // MARK: - UIScrollViewDelegate
529 | extension MenuInfoViewController: UIScrollViewDelegate {
530 |
531 | func scrollViewDidScroll(_ scrollView: UIScrollView) {
532 |
533 | let currentPosition = scrollView.contentOffset.y + scrollView.safeAreaInsets.top
534 |
535 | if currentPosition >= 155 {
536 | fixedMenuInfoContainer.isHidden = false
537 | } else {
538 | fixedMenuInfoContainer.isHidden = true
539 | bottomShadow.isHidden = true
540 | }
541 |
542 | if currentPosition >= 180 {
543 | UIView.animate(withDuration: 0.3) {
544 | self.bottomShadow.alpha = 1
545 | }
546 | bottomShadow.isHidden = false
547 | } else {
548 | UIView.animate(withDuration: 0.3) {
549 | self.bottomShadow.alpha = 0
550 | }
551 | }
552 | }
553 | }
554 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Ranking/RankingCell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RankingCell.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/5/24.
6 | //
7 |
8 | import UIKit
9 | import RxSwift
10 | import RxCocoa
11 | import Then
12 | import SnapKit
13 | import Cosmos
14 |
15 | class RankingCell: UITableViewCell {
16 |
17 | // MARK: - Properties
18 | static let reuseIdentifier = "RankingCell"
19 |
20 | private lazy var container = UIButton().then {
21 | $0.backgroundColor = Color.white
22 | $0.layer.cornerRadius = 12
23 | $0.layer.shadowRadius = 2
24 | $0.layer.shadowOpacity = 0.9
25 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
26 | $0.clipsToBounds = false
27 | }
28 |
29 | private lazy var spacer = UIView().then {
30 | $0.backgroundColor = Color.background
31 | }
32 |
33 | private lazy var crownImage = UIImageView().then {
34 | $0.image = UIImage(icon: .crown)
35 | $0.contentMode = .scaleAspectFit
36 | }
37 |
38 | private lazy var rankingLabel = UILabel().then {
39 | $0.text = "1위"
40 | $0.textColor = Color.breakfast
41 | $0.font = .systemFont(ofSize: 22, weight: .regular)
42 | }
43 |
44 | private lazy var starView = CosmosView().then {
45 | $0.settings.fillMode = .half
46 | $0.settings.updateOnTouch = false
47 | $0.settings.starSize = 24
48 | $0.settings.starMargin = 1
49 | $0.settings.filledImage = UIImage(icon: .filledStar)
50 | $0.settings.emptyImage = UIImage(icon: .emptyStar)
51 | }
52 |
53 | private lazy var menuLabel = UILabel().then {
54 | $0.textColor = Color.darkGray
55 | $0.font = .systemFont(ofSize: 16, weight: .regular)
56 | $0.numberOfLines = 0
57 | }
58 |
59 | private lazy var dateLabel = UILabel().then {
60 | $0.textColor = Color.lightGray
61 | $0.font = .systemFont(ofSize: 14, weight: .regular)
62 | }
63 |
64 | // MARK: - Initialization
65 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
66 | super.init(style: style, reuseIdentifier: reuseIdentifier)
67 |
68 | addView()
69 | setLayout()
70 | }
71 |
72 | required init?(coder: NSCoder) {
73 | fatalError("init(coder:) has not been implemented")
74 | }
75 |
76 | // MARK: - UI
77 | public func configuration(_ data: Ranking, _ mealType: MealType) {
78 | contentView.backgroundColor = Color.background
79 | let color = Color.getMealColor(for: mealType)
80 |
81 | container.layer.shadowColor = color.cgColor
82 | crownImage.tintColor = color
83 | rankingLabel.text = "\(data.ranking)위"
84 | rankingLabel.textColor = color
85 | starView.rating = data.totalScore
86 | menuLabel.text = data.menu
87 | dateLabel.text = data.date.stringToDate(format: "yyyyMMdd").formattingDate(format: "yyyy년 M월 d일 (E)")
88 | }
89 |
90 | func addView() {
91 | contentView.addSubview(spacer)
92 | spacer.addSubview(container)
93 | container.addSubviews(
94 | crownImage, rankingLabel, starView,
95 | menuLabel, dateLabel
96 | )
97 | }
98 |
99 | func setLayout() {
100 | spacer.snp.makeConstraints {
101 | $0.edges.equalToSuperview()
102 | $0.height.equalTo(144)
103 | }
104 | container.snp.makeConstraints {
105 | $0.top.equalTo(spacer.snp.top).inset(2)
106 | $0.horizontalEdges.equalTo(spacer).inset(2)
107 | $0.bottom.equalTo(dateLabel.snp.bottom).offset(16)
108 | }
109 | crownImage.snp.makeConstraints {
110 | $0.width.height.equalTo(20)
111 | $0.top.equalToSuperview().offset(18)
112 | $0.leading.equalToSuperview().offset(24)
113 | }
114 | rankingLabel.snp.makeConstraints {
115 | $0.top.equalToSuperview().offset(16)
116 | $0.leading.equalTo(crownImage.snp.trailing).offset(8)
117 | }
118 | starView.snp.makeConstraints {
119 | $0.top.equalToSuperview().offset(16)
120 | $0.leading.equalTo(rankingLabel.snp.trailing).offset(12)
121 | }
122 | menuLabel.snp.makeConstraints {
123 | $0.top.equalTo(rankingLabel.snp.bottom).offset(8)
124 | $0.leading.trailing.equalToSuperview().inset(24)
125 | }
126 | dateLabel.snp.makeConstraints {
127 | $0.top.equalTo(menuLabel.snp.bottom).offset(2)
128 | $0.leading.equalToSuperview().offset(24)
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Ranking/RankingReactor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RankingReactor.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import Foundation
9 | import ReactorKit
10 |
11 | final class RankingReactor: Reactor {
12 |
13 | // MARK: - Properties
14 | var initialState: State = State()
15 |
16 | // MARK: - Action
17 | enum Action {
18 | case refresh
19 | case fetchRanking
20 | case setMealType(MealType)
21 | }
22 |
23 | // MARK: - Mutation
24 | enum Mutation {
25 | case setMealType(MealType)
26 | case setBreakfastRanking(RankingResponse?)
27 | case setLunchRanking(RankingResponse?)
28 | case setDinnerRanking(RankingResponse?)
29 | }
30 |
31 | // MARK: - State
32 | struct State {
33 | var mealType: MealType = .TYPE_LUNCH
34 | var breakfastRanking: RankingResponse?
35 | var lunchRanking: RankingResponse?
36 | var dinnerRanking: RankingResponse?
37 | }
38 | }
39 |
40 | // MARK: - Mutate
41 | extension RankingReactor {
42 |
43 | private func fetchBreakfastRanking() -> Observable {
44 | return RankingProvider.shared
45 | .getRating(.TYPE_BREAKFAST)
46 | .map { result -> Mutation in
47 | switch result {
48 | case .success(let data):
49 | return .setBreakfastRanking(data)
50 | case .failure:
51 | return .setBreakfastRanking(nil)
52 | }
53 | }
54 | }
55 |
56 | private func fetchLunchRanking() -> Observable {
57 | return RankingProvider.shared
58 | .getRating(.TYPE_LUNCH)
59 | .map { result -> Mutation in
60 | switch result {
61 | case .success(let data):
62 | return .setLunchRanking(data)
63 | case .failure:
64 | return .setLunchRanking(nil)
65 | }
66 | }
67 | }
68 |
69 | private func fetchDinnerRanking() -> Observable {
70 | return RankingProvider.shared
71 | .getRating(.TYPE_DINNER)
72 | .map { result -> Mutation in
73 | switch result {
74 | case .success(let data):
75 | return .setDinnerRanking(data)
76 | case .failure:
77 | return .setDinnerRanking(nil)
78 | }
79 | }
80 | }
81 |
82 | func mutate(action: Action) -> Observable {
83 | switch action {
84 | case .refresh:
85 | return .empty()
86 |
87 | case .fetchRanking:
88 | let breakfastObservable = fetchBreakfastRanking()
89 | let lunchObservable = fetchLunchRanking()
90 | let dinnerObservable = fetchDinnerRanking()
91 |
92 | return Observable.merge(breakfastObservable, lunchObservable, dinnerObservable)
93 |
94 | case .setMealType(let mealType):
95 | return .just(.setMealType(mealType))
96 | }
97 | }
98 |
99 | // MARK: - Reduce
100 | func reduce(state: State, mutation: Mutation) -> State {
101 | var newState = state
102 | switch mutation {
103 |
104 | case .setMealType(let mealType):
105 | newState.mealType = mealType
106 |
107 | case .setBreakfastRanking(let ranking):
108 | newState.breakfastRanking = ranking
109 |
110 | case .setLunchRanking(let ranking):
111 | newState.lunchRanking = ranking
112 |
113 | case .setDinnerRanking(let ranking):
114 | newState.dinnerRanking = ranking
115 | }
116 | return newState
117 | }
118 | }
119 |
120 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Ranking/RankingViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RankingViewController.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 4/30/24.
6 | //
7 |
8 | import UIKit
9 |
10 | final class RankingViewController: BaseVC {
11 |
12 | // MARK: - Properties
13 | private lazy var container = UIView()
14 |
15 | /// navigation bar
16 | private lazy var navigationBarView = UIView().then {
17 | $0.backgroundColor = Color.white
18 | }
19 |
20 | private lazy var navigationBarSeparateLine = UIView().then {
21 | $0.backgroundColor = Color.lightGray
22 | }
23 |
24 | private lazy var navigationBarItemView = UIView()
25 |
26 | private lazy var backButton = UIButton().then {
27 | $0.setImage(UIImage(icon: .leadingArrow), for: .normal)
28 | $0.imageView!.contentMode = .scaleAspectFit
29 | $0.tintColor = Color.black
30 | }
31 |
32 | private lazy var navigationTitle = UILabel().then {
33 | $0.text = "대슐랭 랭킹"
34 | $0.font = .systemFont(ofSize: 18, weight: .medium)
35 | $0.textColor = Color.black
36 | }
37 |
38 | /// scroll view
39 | private lazy var scrollView = UIScrollView().then {
40 | $0.showsVerticalScrollIndicator = false
41 | $0.alwaysBounceVertical = true
42 | $0.contentInsetAdjustmentBehavior = .always
43 | $0.clipsToBounds = false
44 | }
45 |
46 | private let refreshControl = UIRefreshControl()
47 |
48 | private lazy var scrollStackView = UIStackView().then {
49 | $0.axis = .vertical
50 | $0.spacing = 20
51 | $0.distribution = .fill
52 | }
53 |
54 | private lazy var fadingBottomView = FadingView(position: .bottom)
55 |
56 | /// ranking
57 | private lazy var breakfastButton = ScaledButton(scale: 0.95).then {
58 | $0.setTitle("조식 랭킹", for: .normal)
59 | $0.setTitleColor(Color.darkGray, for: .normal)
60 | $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
61 | $0.backgroundColor = Color.breakfast
62 | $0.layer.cornerRadius = 8
63 | $0.layer.shadowRadius = 2
64 | $0.layer.shadowOpacity = 0.5
65 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
66 | $0.layer.shadowColor = Color.getMealColor(for: .TYPE_BREAKFAST).cgColor
67 | }
68 | private lazy var lunchButton = ScaledButton(scale: 0.95).then {
69 | $0.setTitle("중식 랭킹", for: .normal)
70 | $0.setTitleColor(Color.darkGray, for: .normal)
71 | $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
72 | $0.backgroundColor = Color.lunch
73 | $0.layer.cornerRadius = 8
74 | $0.layer.shadowRadius = 2
75 | $0.layer.shadowOpacity = 0.5
76 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
77 | $0.layer.shadowColor = Color.getMealColor(for: .TYPE_LUNCH).cgColor
78 | }
79 |
80 | private lazy var dinnerButton = ScaledButton(scale: 0.95).then {
81 | $0.setTitle("석식 랭킹", for: .normal)
82 | $0.setTitleColor(Color.darkGray, for: .normal)
83 | $0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
84 | $0.backgroundColor = Color.dinner
85 | $0.layer.cornerRadius = 8
86 | $0.layer.shadowRadius = 2
87 | $0.layer.shadowOpacity = 0.5
88 | $0.layer.shadowOffset = CGSize(width: 0, height: 0)
89 | $0.layer.shadowColor = Color.getMealColor(for: .TYPE_DINNER).cgColor
90 | }
91 |
92 | private lazy var rankingTableView = UITableView().then {
93 | $0.backgroundColor = Color.background
94 | $0.register(
95 | RankingCell.self,
96 | forCellReuseIdentifier: RankingCell.reuseIdentifier
97 | )
98 | $0.isScrollEnabled = false
99 | $0.allowsSelection = false
100 | $0.separatorStyle = .none
101 | }
102 |
103 | // MARK: - LifeCycle
104 | override func viewWillAppear(_ animated: Bool) {
105 | super.viewWillAppear(true)
106 | }
107 |
108 | // MARK: - UI
109 | override func addView() {
110 | view.addSubview(container)
111 | /// navigation bar
112 | container.addSubviews(
113 | scrollView, breakfastButton, lunchButton, dinnerButton,
114 | navigationBarView, fadingBottomView
115 | )
116 | navigationBarView.addSubviews(
117 | navigationBarItemView, navigationBarSeparateLine
118 | )
119 | navigationBarItemView.addSubviews(
120 | backButton, navigationTitle
121 | )
122 | /// scroll view
123 | scrollView.addSubview(scrollStackView)
124 | scrollStackView.addArrangedSubviews(
125 | rankingTableView
126 | )
127 | }
128 |
129 | override func setLayout() {
130 | container.snp.makeConstraints {
131 | $0.edges.equalToSuperview()
132 | }
133 | /// navigation bar
134 | navigationBarView.snp.makeConstraints {
135 | $0.top.horizontalEdges.equalToSuperview()
136 | $0.bottom.equalTo(navigationBarItemView.snp.bottom).offset(16)
137 | }
138 | navigationBarItemView.snp.makeConstraints {
139 | $0.height.equalTo(24)
140 | $0.top.equalTo(view.safeAreaLayoutGuide).inset(16)
141 | $0.horizontalEdges.equalToSuperview().inset(16)
142 | }
143 | navigationBarSeparateLine.snp.makeConstraints {
144 | $0.height.equalTo(1)
145 | $0.bottom.horizontalEdges.equalToSuperview()
146 | }
147 | backButton.snp.makeConstraints {
148 | $0.height.leading.equalToSuperview()
149 | }
150 | navigationTitle.snp.makeConstraints {
151 | $0.height.equalToSuperview()
152 | $0.leading.equalTo(backButton.snp.trailing).offset(10)
153 | }
154 | /// scroll view
155 | scrollView.snp.makeConstraints {
156 | $0.top.equalTo(navigationBarView.snp.bottom).offset(20)
157 | $0.horizontalEdges.bottom.equalTo(view.safeAreaLayoutGuide)
158 | }
159 | scrollStackView.snp.makeConstraints {
160 | $0.verticalEdges.equalToSuperview()
161 | $0.horizontalEdges.equalToSuperview().inset(16)
162 | }
163 | fadingBottomView.snp.makeConstraints {
164 | $0.horizontalEdges.equalToSuperview()
165 | $0.bottom.equalTo(container.snp.bottom)
166 | $0.height.equalTo(bound.height / 12)
167 | }
168 | /// ranking
169 | breakfastButton.snp.makeConstraints {
170 | $0.top.equalTo(navigationBarView.snp.bottom).offset(20)
171 | $0.leading.equalTo(scrollStackView.snp.leading)
172 | $0.width.equalTo(bound.width / 3 - 20)
173 | $0.height.equalTo(26)
174 | }
175 | lunchButton.snp.makeConstraints {
176 | $0.top.equalTo(navigationBarView.snp.bottom).offset(20)
177 | $0.width.equalTo(bound.width / 3 - 20)
178 | $0.height.equalTo(26)
179 | $0.centerX.equalToSuperview()
180 | }
181 | dinnerButton.snp.makeConstraints {
182 | $0.top.equalTo(navigationBarView.snp.bottom).offset(20)
183 | $0.trailing.equalTo(scrollStackView.snp.trailing)
184 | $0.width.equalTo(bound.width / 3 - 20)
185 | $0.height.equalTo(26)
186 | }
187 | rankingTableView.snp.makeConstraints {
188 | $0.top.equalTo(scrollView.snp.top).offset(55)
189 | $0.width.equalTo(scrollView.snp.width).inset(16)
190 | $0.height.equalTo(1440)
191 | $0.centerX.equalToSuperview()
192 | }
193 | }
194 |
195 | private func setUI(for type: MealType) {
196 | let buttonColorMapping: [(button: UIButton, isSelected: Bool)] = [
197 | (breakfastButton, type == .TYPE_BREAKFAST),
198 | (lunchButton, type == .TYPE_LUNCH),
199 | (dinnerButton, type == .TYPE_DINNER)
200 | ]
201 |
202 | buttonColorMapping.forEach { mapping in
203 | let button = mapping.button
204 | let color = mapping.isSelected ? Color.getMealColor(for: type) : Color.white
205 | let titleColor = mapping.isSelected ? Color.white : Color.darkGray
206 |
207 | button.backgroundColor = color
208 | button.setTitleColor(titleColor, for: .normal)
209 | }
210 | }
211 |
212 | // MARK: - Reactor
213 | override func bindView(reactor: RankingReactor) {
214 | backButton.rx.tap
215 | .subscribe(onNext: { [weak self] in
216 | self?.navigationController?.popViewController(animated: true)
217 | })
218 | .disposed(by: disposeBag)
219 |
220 | breakfastButton.rx.tap
221 | .map { Reactor.Action.setMealType(.TYPE_BREAKFAST) }
222 | .bind(to: reactor.action)
223 | .disposed(by: disposeBag)
224 |
225 | lunchButton.rx.tap
226 | .map { Reactor.Action.setMealType(.TYPE_LUNCH) }
227 | .bind(to: reactor.action)
228 | .disposed(by: disposeBag)
229 |
230 | dinnerButton.rx.tap
231 | .map { Reactor.Action.setMealType(.TYPE_DINNER) }
232 | .bind(to: reactor.action)
233 | .disposed(by: disposeBag)
234 | }
235 |
236 | override func bindAction(reactor: RankingReactor) {
237 | reactor.action.onNext(.fetchRanking)
238 | }
239 |
240 | override func bindState(reactor: RankingReactor) {
241 | reactor.state.map { $0.mealType }
242 | .distinctUntilChanged()
243 | .subscribe(onNext: { [weak self] type in
244 | self?.rankingTableView.reloadData()
245 | self?.setUI(for: type)
246 | })
247 | .disposed(by: disposeBag)
248 |
249 | reactor.state.map { $0.mealType }
250 | .compactMap { mealType -> [Ranking]? in
251 | switch mealType {
252 | case .TYPE_BREAKFAST:
253 | return reactor.currentState.breakfastRanking?.ranking
254 | case .TYPE_LUNCH:
255 | return reactor.currentState.lunchRanking?.ranking
256 | case .TYPE_DINNER:
257 | return reactor.currentState.dinnerRanking?.ranking
258 | }
259 | }
260 | .bind(to: rankingTableView.rx.items(
261 | cellIdentifier: RankingCell.reuseIdentifier,
262 | cellType: RankingCell.self)
263 | ) { _, ranking, cell in
264 | cell.configuration(ranking, reactor.currentState.mealType)
265 | }
266 | .disposed(by: disposeBag)
267 | }
268 |
269 | }
270 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Review/ReviewReactor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReviewReactor.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import Foundation
9 | import ReactorKit
10 |
11 | final class ReviewReactor: Reactor {
12 |
13 | // MARK: - Properties
14 | var initialState: State
15 |
16 | // MARK: - Action
17 | enum Action {
18 | case completeReview
19 | case setReviewText(String)
20 | case setReviewScore(Double)
21 | }
22 |
23 | // MARK: - Mutation
24 | enum Mutation {
25 | case setReviewText(String)
26 | case setReviewScore(Double)
27 | }
28 |
29 | // MARK: - State
30 | struct State {
31 | var menuId: Int
32 | var reviewText: String = ""
33 | var score: Double = 0.0
34 | }
35 |
36 | init(menuId: Int) {
37 | self.initialState = State(menuId: menuId)
38 | }
39 | }
40 |
41 | // MARK: - Mutate
42 | extension ReviewReactor {
43 |
44 | private func postReview() -> Observable {
45 | return RatingProvider.shared
46 | .postRating(
47 | currentState.menuId,
48 | RatingRequest(
49 | score: currentState.score,
50 | comment: currentState.reviewText
51 | )
52 | )
53 | .flatMap { result -> Observable in
54 | switch result {
55 | case .success(_):
56 | return Observable.empty()
57 | case .failure(_):
58 | return Observable.empty()
59 | }
60 | }
61 | }
62 |
63 | func mutate(action: Action) -> Observable {
64 | switch action {
65 |
66 | case let .setReviewText(text):
67 | return .just(.setReviewText(text))
68 |
69 | case let .setReviewScore(rating):
70 | return .just(.setReviewScore(rating))
71 |
72 | case .completeReview:
73 | return postReview()
74 | }
75 | }
76 |
77 | // MARK: - Reduce
78 | func reduce(state: State, mutation: Mutation) -> State {
79 | var newState = state
80 | switch mutation {
81 |
82 | case .setReviewText(let reviewText):
83 | newState.reviewText = reviewText
84 |
85 | case .setReviewScore(let score):
86 | newState.score = score
87 | }
88 | return newState
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Review/ReviewViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReviewViewController.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 4/30/24.
6 | //
7 |
8 | import UIKit
9 | import Cosmos
10 |
11 | final class ReviewViewController: BaseVC {
12 |
13 | // MARK: - Properties
14 | private lazy var container = UIView()
15 |
16 | /// navigation bar
17 | private lazy var navigationBarView = UIView().then {
18 | $0.backgroundColor = Color.white
19 | }
20 |
21 | private lazy var navigationBarSeparateLine = UIView().then {
22 | $0.backgroundColor = Color.lightGray
23 | }
24 |
25 | private lazy var navigationBarItemView = UIView()
26 |
27 | private lazy var backButton = UIButton().then {
28 | $0.setImage(UIImage(icon: .leadingArrow), for: .normal)
29 | $0.imageView!.contentMode = .scaleAspectFit
30 | $0.tintColor = Color.black
31 | }
32 |
33 | private lazy var navigationTitle = UILabel().then {
34 | $0.text = "급식 리뷰"
35 | $0.font = .systemFont(ofSize: 18, weight: .medium)
36 | $0.textColor = Color.black
37 | }
38 |
39 | /// review text view
40 | private lazy var reviewContainer = UIView().then {
41 | $0.backgroundColor = Color.white
42 | $0.layer.cornerRadius = 12
43 | $0.layer.shadowColor = Color.black.cgColor
44 | $0.layer.shadowOpacity = 0.02
45 | $0.layer.shadowOffset = CGSize(width: 0, height: 4)
46 | $0.layer.shadowRadius = 10
47 | }
48 |
49 | private lazy var reviewTextView = UITextView().then {
50 | $0.font = .systemFont(ofSize: 20, weight: .regular)
51 | $0.textColor = Color.darkGray
52 | $0.delegate = self
53 | $0.isScrollEnabled = false
54 | textViewDidChange($0)
55 | }
56 |
57 | private lazy var reviewPlaceHolder = UILabel().then {
58 | $0.text = "리뷰를 작성해주세요."
59 | $0.font = .systemFont(ofSize: 20, weight: .regular)
60 | $0.textColor = Color.lightGray
61 | }
62 |
63 | private lazy var reviewTextCountingLabel = UILabel().then {
64 | $0.text = "0 / 50"
65 | $0.font = .systemFont(ofSize: 16, weight: .semibold)
66 | $0.textColor = Color.darkGray
67 | }
68 |
69 | private lazy var reviewCompleteButton = ScaledButton(scale: 0.95).then {
70 | $0.setTitle("완료", for: .normal)
71 | $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
72 | $0.setTitleColor(Color.darkGray, for: .normal)
73 | }
74 |
75 | private lazy var starView = CosmosView().then {
76 | $0.rating = 0.0
77 | $0.settings.fillMode = .half
78 | $0.settings.starSize = 30
79 | $0.settings.starMargin = 4
80 | $0.settings.minTouchRating = 0.0
81 | $0.settings.filledImage = UIImage(icon: .filledStar)
82 | $0.settings.emptyImage = UIImage(icon: .emptyStar)
83 | }
84 |
85 | // MARK: - LifeCycle
86 | override func viewWillAppear(_ animated: Bool) {
87 | super.viewWillAppear(true)
88 |
89 | print("\(type(of: self)): \(#function)")
90 | }
91 |
92 | // MARK: - UI
93 | override func addView() {
94 | view.addSubview(container)
95 | /// navigation bar
96 | container.addSubviews(
97 | starView, reviewContainer, navigationBarView
98 | )
99 | navigationBarView.addSubviews(
100 | navigationBarItemView, navigationBarSeparateLine
101 | )
102 | navigationBarItemView.addSubviews(
103 | backButton, navigationTitle
104 | )
105 | reviewContainer.addSubviews(
106 | reviewTextView, reviewTextCountingLabel, reviewCompleteButton
107 | )
108 | reviewTextView.addSubview(reviewPlaceHolder)
109 | }
110 |
111 | override func setLayout() {
112 | container.snp.makeConstraints {
113 | $0.edges.equalToSuperview()
114 | }
115 | /// navigation bar
116 | navigationBarView.snp.makeConstraints {
117 | $0.top.horizontalEdges.equalToSuperview()
118 | $0.bottom.equalTo(navigationBarItemView.snp.bottom).offset(16)
119 | }
120 | navigationBarItemView.snp.makeConstraints {
121 | $0.height.equalTo(24)
122 | $0.top.equalTo(view.safeAreaLayoutGuide).inset(16)
123 | $0.horizontalEdges.equalToSuperview().inset(16)
124 | }
125 | navigationBarSeparateLine.snp.makeConstraints {
126 | $0.height.equalTo(1)
127 | $0.bottom.horizontalEdges.equalToSuperview()
128 | }
129 | backButton.snp.makeConstraints {
130 | $0.height.leading.equalToSuperview()
131 | }
132 | navigationTitle.snp.makeConstraints {
133 | $0.height.equalToSuperview()
134 | $0.leading.equalTo(backButton.snp.trailing).offset(10)
135 | }
136 | /// reivew text view
137 | reviewContainer.snp.makeConstraints {
138 | $0.top.equalTo(navigationBarView.snp.bottom).offset(20)
139 | $0.bottom.equalTo(reviewTextView.snp.bottom).offset(54)
140 | $0.width.equalToSuperview().inset(16)
141 | $0.centerX.equalToSuperview()
142 | }
143 | reviewTextView.snp.makeConstraints {
144 | $0.top.equalToSuperview().offset(14)
145 | $0.height.equalTo(40)
146 | $0.width.equalToSuperview().inset(16)
147 | $0.centerX.equalToSuperview()
148 | }
149 | reviewPlaceHolder.snp.makeConstraints {
150 | $0.leading.equalTo(reviewTextView).offset(4)
151 | $0.centerY.equalToSuperview()
152 | }
153 | reviewTextCountingLabel.snp.makeConstraints {
154 | $0.height.equalTo(18)
155 | $0.bottom.equalToSuperview().inset(20)
156 | $0.leading.equalToSuperview().inset(22)
157 | }
158 | reviewCompleteButton.snp.makeConstraints {
159 | $0.height.equalTo(18)
160 | $0.bottom.equalToSuperview().inset(20)
161 | $0.trailing.equalToSuperview().inset(22)
162 | }
163 | starView.snp.makeConstraints {
164 | $0.top.equalTo(reviewContainer.snp.bottom).offset(20)
165 | $0.leading.equalToSuperview().offset(26)
166 | }
167 | }
168 |
169 | // MARK: - Reactor
170 | override func bindView(reactor: ReviewReactor) {
171 | backButton.rx.tap
172 | .subscribe(onNext: { [weak self] in
173 | self?.navigationController?.popViewController(animated: true)
174 | })
175 | .disposed(by: disposeBag)
176 |
177 | reviewCompleteButton.rx.tap
178 | .subscribe(onNext: { [weak self] in
179 | let action = ReviewReactor.Action.completeReview
180 | reactor.action.onNext(action)
181 | self?.navigationController?.popViewController(animated: true)
182 | })
183 | .disposed(by: disposeBag)
184 |
185 | reviewTextView.rx.text.orEmpty
186 | .map(ReviewReactor.Action.setReviewText)
187 | .bind(to: reactor.action)
188 | .disposed(by: disposeBag)
189 |
190 | starView.rx.didFinishTouchingCosmos
191 | .onNext { score in
192 | let action = ReviewReactor.Action.setReviewScore(score)
193 | reactor.action.onNext(action)
194 | }
195 | }
196 |
197 | override func bindAction(reactor: ReviewReactor) {
198 | }
199 |
200 | override func bindState(reactor: ReviewReactor) {
201 | reactor.state.map { $0.reviewText }
202 | .bind(to: reviewTextView.rx.text)
203 | .disposed(by: disposeBag)
204 |
205 | reactor.state.map { $0.score }
206 | .bind(to: starView.rx.rating)
207 | .disposed(by: disposeBag)
208 | }
209 | }
210 |
211 | extension ReviewViewController {
212 |
213 | /// keypad down
214 | override func touchesBegan(_ touches: Set, with event: UIEvent?) {
215 | super.touchesBegan(touches, with: event)
216 |
217 | self.view.endEditing(true)
218 | }
219 | }
220 |
221 | extension ReviewViewController: UITextViewDelegate {
222 |
223 | func textViewDidChange(_ textView: UITextView) {
224 | /// reactive text view
225 | let size = CGSize(width: self.reviewContainer.frame.width - 32, height: .infinity)
226 | textView.constraints.forEach {
227 | if $0.firstAttribute == .height {
228 | $0.constant = textView.sizeThatFits(size).height
229 | }
230 | }
231 | /// limit the number of review text count
232 | let textCount = textView.text.count < 50 ? textView.text.count : 50
233 | let maxCount = 50
234 | textView.text = String(textView.text.prefix(maxCount))
235 |
236 | reviewTextCountingLabel.text = "\(textCount) / \(maxCount)"
237 | reviewTextCountingLabel.textColor = textCount >= maxCount ? Color.error : Color.darkGray
238 | }
239 |
240 | func textViewDidBeginEditing(_ textView: UITextView) {
241 | reviewPlaceHolder.isHidden = true
242 | }
243 |
244 | func textViewDidEndEditing(_ textView: UITextView) {
245 | reviewPlaceHolder.isHidden = !textView.text.isEmpty
246 | }
247 |
248 | func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
249 | let currentText = textView.text ?? ""
250 | guard let stringRange = Range(range, in: currentText) else { return false }
251 | let updatedText = currentText.replacingCharacters(in: stringRange, with: text)
252 | return updatedText.count <= 100
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Setting/SettingReactor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingReactor.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import Foundation
9 | import ReactorKit
10 |
11 | final class SettingReactor: Reactor {
12 |
13 | // MARK: - Properties
14 | var initialState: State = State()
15 |
16 | // MARK: - Action
17 | enum Action {
18 |
19 | }
20 |
21 | // MARK: - Mutation
22 | enum Mutation {
23 |
24 | }
25 |
26 | // MARK: - State
27 | struct State {
28 |
29 | }
30 | }
31 |
32 | // MARK: - Mutate
33 | extension SettingReactor {
34 |
35 | func mutate(action: Action) -> Observable {
36 |
37 | }
38 |
39 | // MARK: - Reduce
40 | func reduce(state: State, mutation: Mutation) -> State {
41 |
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Presentation/Scenes/Setting/SettingViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingViewController.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 4/30/24.
6 | //
7 |
8 | import UIKit
9 | import RxCocoa
10 | import SnapKit
11 | import Then
12 | import StoreKit
13 |
14 | final class SettingViewController: BaseVC {
15 |
16 | // MARK: - Properties
17 | private lazy var container = UIView()
18 |
19 | /// navigation bar
20 | private lazy var navigationBarView = UIView().then {
21 | $0.backgroundColor = Color.white
22 | }
23 |
24 | private lazy var navigationBarSeparateLine = UIView().then {
25 | $0.backgroundColor = Color.lightGray
26 | }
27 |
28 | private lazy var navigationBarItemView = UIView()
29 |
30 | private lazy var backButton = UIButton().then {
31 | $0.setImage(UIImage(icon: .leadingArrow), for: .normal)
32 | $0.imageView!.contentMode = .scaleAspectFit
33 | $0.tintColor = Color.black
34 | }
35 |
36 | private lazy var navigationTitle = UILabel().then {
37 | $0.text = "설정"
38 | $0.font = .systemFont(ofSize: 18, weight: .medium)
39 | $0.textColor = Color.black
40 | }
41 |
42 | /// setting
43 | private lazy var settingButtonStackView = UIStackView().then {
44 | $0.axis = .vertical
45 | $0.spacing = 20
46 | $0.distribution = .fill
47 | }
48 |
49 | private lazy var privacyPolicyButton = ScaledButton(
50 | scale: 0.98, backgroundColor: Color.white
51 | ).then {
52 | $0.layer.cornerRadius = 8
53 | }
54 |
55 | private lazy var privacyPolicyContainerLeadingItem = UILabel().then {
56 | $0.text = "개인정보 처리방침"
57 | $0.textColor = Color.black
58 | $0.font = .systemFont(ofSize: 16, weight: .regular)
59 | }
60 |
61 | private lazy var privacyPolicyContainerTrailingItem = UIImageView().then {
62 | $0.image = UIImage(icon: .trailingArrow)
63 | $0.contentMode = .scaleAspectFit
64 | $0.tintColor = Color.black
65 | }
66 |
67 | private lazy var deleteReviewButton = ScaledButton(
68 | scale: 0.98, backgroundColor: Color.white
69 | ).then {
70 | $0.layer.cornerRadius = 12
71 | }
72 |
73 | private lazy var deleteReviewLeadingItem = UILabel().then {
74 | $0.text = "작성한 리뷰 삭제 요청"
75 | $0.textColor = Color.black
76 | $0.font = .systemFont(ofSize: 16, weight: .regular)
77 | }
78 |
79 | private lazy var deleteReviewTrailingItem = UIImageView().then {
80 | $0.image = UIImage(icon: .trailingArrow)
81 | $0.contentMode = .scaleAspectFit
82 | $0.tintColor = Color.black
83 | }
84 |
85 | private lazy var appVersionButton = ScaledButton(
86 | scale: 0.98, backgroundColor: Color.white
87 | ).then {
88 | $0.layer.cornerRadius = 12
89 | }
90 |
91 | private lazy var appVersionLeadingItem = UILabel().then {
92 | $0.text = "앱 버전"
93 | $0.textColor = Color.black
94 | $0.font = .systemFont(ofSize: 16, weight: .regular)
95 | }
96 |
97 | private lazy var appVersionTrailingItem = UILabel().then {
98 | $0.text = appVersion
99 | $0.textColor = Color.error
100 | $0.font = .systemFont(ofSize: 15, weight: .regular)
101 | }
102 |
103 | // MARK: - LifeCycle
104 | override func viewWillAppear(_ animated: Bool) {
105 | super.viewWillAppear(true)
106 |
107 | }
108 |
109 | // MARK: - UI
110 | override func addView() {
111 | view.addSubview(container)
112 | /// navigation bar
113 | container.addSubviews(
114 | settingButtonStackView, navigationBarView
115 | )
116 | navigationBarView.addSubviews(
117 | navigationBarItemView, navigationBarSeparateLine
118 | )
119 | navigationBarItemView.addSubviews(
120 | backButton, navigationTitle
121 | )
122 | /// setting
123 | settingButtonStackView.addArrangedSubviews(
124 | privacyPolicyButton, deleteReviewButton, appVersionButton
125 | )
126 | privacyPolicyButton.addSubviews(
127 | privacyPolicyContainerLeadingItem, privacyPolicyContainerTrailingItem
128 | )
129 | deleteReviewButton.addSubviews(
130 | deleteReviewLeadingItem, deleteReviewTrailingItem
131 | )
132 | appVersionButton.addSubviews(
133 | appVersionLeadingItem, appVersionTrailingItem
134 | )
135 | }
136 |
137 | override func setLayout() {
138 | container.snp.makeConstraints {
139 | $0.edges.equalToSuperview()
140 | }
141 | /// navigation bar
142 | navigationBarView.snp.makeConstraints {
143 | $0.top.horizontalEdges.equalToSuperview()
144 | $0.bottom.equalTo(navigationBarItemView.snp.bottom).offset(16)
145 | }
146 | navigationBarItemView.snp.makeConstraints {
147 | $0.height.equalTo(24)
148 | $0.top.equalTo(view.safeAreaLayoutGuide).inset(16)
149 | $0.horizontalEdges.equalToSuperview().inset(16)
150 | }
151 | navigationBarSeparateLine.snp.makeConstraints {
152 | $0.height.equalTo(1)
153 | $0.bottom.horizontalEdges.equalToSuperview()
154 | }
155 | backButton.snp.makeConstraints {
156 | $0.height.leading.equalToSuperview()
157 | }
158 | navigationTitle.snp.makeConstraints {
159 | $0.height.equalToSuperview()
160 | $0.leading.equalTo(backButton.snp.trailing).offset(10)
161 | }
162 | /// setting
163 | settingButtonStackView.snp.makeConstraints {
164 | $0.top.equalTo(navigationBarView.snp.bottom).offset(20)
165 | $0.horizontalEdges.equalToSuperview().inset(16)
166 | }
167 | privacyPolicyButton.snp.makeConstraints {
168 | $0.top.equalTo(settingButtonStackView.snp.top)
169 | $0.height.equalTo(50)
170 | $0.width.equalToSuperview()
171 | }
172 | privacyPolicyContainerLeadingItem.snp.makeConstraints {
173 | $0.leading.equalToSuperview().offset(16)
174 | $0.centerY.equalToSuperview()
175 | }
176 | privacyPolicyContainerTrailingItem.snp.makeConstraints {
177 | $0.width.height.equalTo(20)
178 | $0.trailing.equalToSuperview().inset(16)
179 | $0.centerY.equalToSuperview()
180 | }
181 | deleteReviewButton.snp.makeConstraints {
182 | $0.height.equalTo(50)
183 | $0.width.equalToSuperview()
184 | }
185 | deleteReviewLeadingItem.snp.makeConstraints {
186 | $0.leading.equalToSuperview().offset(16)
187 | $0.centerY.equalToSuperview()
188 | }
189 | deleteReviewTrailingItem.snp.makeConstraints {
190 | $0.trailing.equalToSuperview().inset(16)
191 | $0.centerY.equalToSuperview()
192 | }
193 | appVersionButton.snp.makeConstraints {
194 | $0.height.equalTo(50)
195 | $0.width.equalToSuperview()
196 | }
197 | appVersionLeadingItem.snp.makeConstraints {
198 | $0.leading.equalToSuperview().offset(16)
199 | $0.centerY.equalToSuperview()
200 | }
201 | appVersionTrailingItem.snp.makeConstraints {
202 | $0.trailing.equalToSuperview().inset(16)
203 | $0.centerY.equalToSuperview()
204 | }
205 | }
206 |
207 | // MARK: - Reactor
208 | override func bindView(reactor: SettingReactor) {
209 | backButton.rx.tap
210 | .subscribe(onNext: { [weak self] in
211 | self?.navigationController?.popViewController(animated: true)
212 | })
213 | .disposed(by: disposeBag)
214 |
215 | privacyPolicyButton.rx.tap
216 | .subscribe(onNext: { _ in
217 | if let url = URL(string: "https://min-gyu.notion.site/43f3fa6077c346c692359f790d79cd7a?pvs=74") {
218 | UIApplication.shared.open(url, options: [:], completionHandler: nil)
219 | }
220 | })
221 | .disposed(by: disposeBag)
222 |
223 | deleteReviewButton.rx.tap
224 | .subscribe(onNext: { _ in
225 | let subjectEncoded = "[대슐랭 가이드] 리뷰 삭제 요청".addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
226 | let bodyEncoded = "예시 20240101 / 점심 / 내용 -> 삭제 부탁드립니다".addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
227 | let mailtoString = "mailto:dev.gyuuu@gmail.com?subject=\(subjectEncoded)&body=\(bodyEncoded)"
228 | if let mailtoURL = URL(string: mailtoString), UIApplication.shared.canOpenURL(mailtoURL) {
229 | UIApplication.shared.open(mailtoURL, options: [:], completionHandler: nil)
230 | }
231 | })
232 | .disposed(by: disposeBag)
233 |
234 | appVersionButton.rx.tap
235 | .subscribe(onNext: { _ in
236 | SKStoreReviewController.requestReview()
237 | })
238 | .disposed(by: disposeBag)
239 | }
240 |
241 | override func bindAction(reactor: SettingReactor) {
242 | }
243 |
244 | override func bindState(reactor: SettingReactor) {
245 |
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Assets/Color/Color.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Color.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/1/24.
6 | //
7 |
8 | import UIKit
9 |
10 | public class Color {
11 |
12 | // MARK: - Component Color
13 | public static let background: UIColor = .init(hex: "#F9F9F9")
14 | public static let breakfast: UIColor = .init(hex: "#FFAF51")
15 | public static let lunch: UIColor = .init(hex: "#ABC97B")
16 | public static let dinner: UIColor = .init(hex: "#A18CF6")
17 |
18 | // MARK: - UI Color
19 | public static let darkGray: UIColor = .init(hex: "#4E4D4D")
20 | public static let lightGray: UIColor = .init(hex: "#D7D7D7")
21 | public static let gray: UIColor = .init(hex: "#A0A0A0")
22 | public static let black: UIColor = .init(hex: "#292D32")
23 | public static let white: UIColor = .init(hex: "#FFFFFF")
24 | public static let error: UIColor = .init(hex: "#C52222")
25 |
26 | static func getMealColor(for type: MealType) -> UIColor {
27 | switch type {
28 | case .TYPE_BREAKFAST:
29 | return Color.breakfast
30 | case .TYPE_LUNCH:
31 | return Color.lunch
32 | case .TYPE_DINNER:
33 | return Color.dinner
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Assets/Icon/Icon.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Icon.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import UIKit
9 |
10 | public enum Icon: String {
11 |
12 | // MARK: - logo
13 | case logo = "logo"
14 |
15 | // MARK: - Icon
16 | case ranking = "ranking"
17 | case setting = "setting"
18 | case review = "review"
19 | case crown = "crown"
20 |
21 | /// start
22 | case emptyStar = "star_empty"
23 | case filledStar = "star_filled"
24 |
25 | /// arrow
26 | case bottomArrow = "arrow_down"
27 | case leadingArrow = "arrow_left"
28 | case trailingArrow = "arrow_right"
29 |
30 | /// anonymous profile
31 | case cat = "cat"
32 | case rabbit = "rabbit"
33 | case snake = "snake"
34 | case elephant = "elephant"
35 | case tiger = "tiger"
36 |
37 | // MARK: - Food
38 | case taco = "taco"
39 | case burger = "burger"
40 | case ramen = "ramen"
41 | }
42 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Assets/Icon/UIImage+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIImage+Ext.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import UIKit
9 |
10 | extension UIImage {
11 |
12 | convenience init?(icon: Icon) {
13 | self
14 | .init(named: icon.rawValue)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Component/FadingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FadingView.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/9/24.
6 | //
7 |
8 | import UIKit
9 |
10 | class FadingView: BaseView {
11 |
12 | private var position: FadingPosition?
13 |
14 | enum FadingPosition {
15 | case top
16 | case bottom
17 | }
18 |
19 | init(
20 | position: FadingPosition
21 | ) {
22 | super.init()
23 |
24 | self.position = position
25 | setupFadeLayer()
26 | }
27 |
28 | required init?(coder: NSCoder) {
29 | fatalError("init(coder:) has not been implemented")
30 | }
31 |
32 | private func setupFadeLayer() {
33 | let fadeLayer = CAGradientLayer()
34 | fadeLayer.frame = CGRect(x: 0, y: 0, width: bound.width, height: bound.height / 12)
35 | fadeLayer.colors = [Color.background.withAlphaComponent(0).cgColor,
36 | Color.background.withAlphaComponent(0.75).cgColor,
37 | Color.background.withAlphaComponent(1).cgColor]
38 | fadeLayer.startPoint = CGPoint(x: 0.5, y: position == .top ? 1 : 0)
39 | fadeLayer.endPoint = CGPoint(x: 0.5, y: position == .top ? 0 : 1)
40 | layer.addSublayer(fadeLayer)
41 | isUserInteractionEnabled = false
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Component/ScaledButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScaledButton.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/4/24.
6 | //
7 |
8 | import UIKit
9 |
10 | class ScaledButton: UIButton {
11 |
12 | private var defaultBackgroundColor: UIColor?
13 | private var scale: CGFloat = 0
14 |
15 | override var isHighlighted: Bool {
16 | didSet {
17 | UIView.animate(withDuration: 0.15) {
18 | self.transform = self.isHighlighted
19 | ? CGAffineTransform(scaleX: self.scale, y: self.scale)
20 | : .identity
21 | if self.defaultBackgroundColor != nil {
22 | self.backgroundColor = self.isHighlighted
23 | ? self.defaultBackgroundColor?.darken(by: 0.1)
24 | : self.defaultBackgroundColor
25 | }
26 | }
27 | }
28 | }
29 |
30 | init(
31 | scale: CGFloat,
32 | backgroundColor: UIColor? = nil
33 | ) {
34 | super.init(frame: .zero)
35 |
36 | self.defaultBackgroundColor = backgroundColor
37 | self.scale = scale
38 | self.backgroundColor = backgroundColor
39 | }
40 |
41 | required init?(coder: NSCoder) {
42 | fatalError("init(coder:) has not been implemented")
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Enum/MealType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MealType.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/1/24.
6 | //
7 |
8 | import Foundation
9 |
10 | enum MealType: String, Codable {
11 | case TYPE_BREAKFAST
12 | case TYPE_LUNCH
13 | case TYPE_DINNER
14 | }
15 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Extension/Foundation/Date+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Date+Ext.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Date {
11 |
12 | func formattingDate(format: String) -> String {
13 | let dateFormatter = DateFormatter()
14 | dateFormatter.dateFormat = format
15 | dateFormatter.locale = Locale(identifier: "ko_KR")
16 | return dateFormatter.string(from: self)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Extension/Foundation/String+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String+Ext.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/8/24.
6 | //
7 |
8 | import Foundation
9 |
10 | extension String {
11 |
12 | func stringToDate(format: String) -> Date {
13 | let dateFormatter = DateFormatter()
14 | dateFormatter.dateFormat = format
15 | dateFormatter.locale = Locale(identifier: "ko_KR")
16 | return dateFormatter.date(from: self) ?? Date()
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Extension/UIKit/UIColor+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIColor+Ext.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/1/24.
6 | //
7 |
8 | import UIKit
9 |
10 | extension UIColor {
11 |
12 | convenience init(hex: String) {
13 | let hex = hex.trimmingCharacters(
14 | in: CharacterSet.alphanumerics.inverted
15 | )
16 | var int: UInt64 = 0
17 | Scanner(string: hex).scanHexInt64(&int)
18 | self
19 | .init(
20 | red: CGFloat(int >> 16) / 255,
21 | green: CGFloat(int >> 8 & 0xFF) / 255,
22 | blue: CGFloat(int & 0xFF) / 255,
23 | alpha: 1
24 | )
25 | }
26 |
27 | func darken(by percentage: CGFloat) -> UIColor {
28 | var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
29 |
30 | getRed(&red, green: &green, blue: &blue, alpha: &alpha)
31 |
32 | red = max(0.0, min(1.0, red - percentage))
33 | green = max(0.0, min(1.0, green - percentage))
34 | blue = max(0.0, min(1.0, blue - percentage))
35 |
36 | return UIColor(red: red, green: green, blue: blue, alpha: alpha)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Extension/UIKit/UILabel+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UILabel+Ext.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/3/24.
6 | //
7 |
8 | import UIKit
9 |
10 | extension UILabel {
11 |
12 | func setLineSpacing(lineSpacing: CGFloat = 0.0, alignment: NSTextAlignment = .center) {
13 | guard let labelText = self.text else { return }
14 |
15 | let paragraphStyle = NSMutableParagraphStyle()
16 | paragraphStyle.lineSpacing = lineSpacing
17 | paragraphStyle.lineHeightMultiple = 0
18 | paragraphStyle.alignment = alignment
19 |
20 | let attributedString = NSMutableAttributedString(string: labelText)
21 | attributedString.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: attributedString.length))
22 |
23 | self.attributedText = attributedString
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Extension/UIKit/UINavigationController+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UINavigationController.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import UIKit
9 |
10 | extension UINavigationController: ObservableObject, UIGestureRecognizerDelegate {
11 |
12 | override open func viewDidLoad() {
13 | super.viewDidLoad()
14 |
15 | interactivePopGestureRecognizer?.delegate = self
16 | }
17 |
18 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
19 | return viewControllers.count > 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Extension/UIKit/UIStackView+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIStackView+Ext.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import UIKit
9 |
10 | extension UIStackView {
11 |
12 | func addArrangedSubviews(_ views: UIView...) {
13 | views.forEach(addArrangedSubview(_:))
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/DaechelinGuide/DaechelinGuide/Sources/Shared/Extension/UIKit/UIView+Ext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIView+Ext.swift
3 | // DaechelinGuide
4 | //
5 | // Created by 이민규 on 5/2/24.
6 | //
7 |
8 | import UIKit
9 |
10 | extension UIView {
11 |
12 | func addSubviews(_ subView: UIView...) {
13 | subView.forEach(addSubview(_:))
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 대슐랭 가이드 - 대소고 급식 앱
2 |
3 | - 대구소프트웨어고등학교의 급식을 리뷰하고 랭킹을 매기는 서비스 입니다.
4 | - 다운로드 : [App Store 링크](https://apps.apple.com/us/app/%EB%8C%80%EC%8A%90%EB%9E%AD-%EA%B0%80%EC%9D%B4%EB%93%9C-%EB%8C%80%EC%86%8C%EA%B3%A0-%EA%B8%89%EC%8B%9D-%EC%95%B1/id1671086233)
5 |
6 | ## 미리보기
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------