diff --git a/iOS/Projects/Features/Record/Sources/WorkoutSummary/ParticipantsCollectionViewCell.swift b/iOS/Projects/Features/Record/Sources/WorkoutSummary/ParticipantsCollectionViewCell.swift new file mode 100644 index 00000000..b79a9ccb --- /dev/null +++ b/iOS/Projects/Features/Record/Sources/WorkoutSummary/ParticipantsCollectionViewCell.swift @@ -0,0 +1,193 @@ +// +// ParticipantsCollectionViewCell.swift +// RecordFeature +// +// Created by 홍승현 on 11/16/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import DesignSystem +import UIKit + +// MARK: - ParticipantsCollectionViewCell + +final class ParticipantsCollectionViewCell: UICollectionViewCell { + // MARK: UI Components + + private let profileImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.backgroundColor = DesignSystemColor.primaryBackGround + imageView.layer.cornerRadius = Metrics.profileImageSize * 0.5 + imageView.layer.cornerCurve = .continuous + imageView.clipsToBounds = true + return imageView + }() + + private let nicknameLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .title3) + label.text = "S043_홍승현" + label.accessibilityHint = Strings.nicknameAccessibilityHint + return label + }() + + private let distanceLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .title3, with: .traitBold) + label.text = "4.3km" + label.accessibilityHint = Strings.workoutDistanceAccessibilityHint + return label + }() + + private let markingCircularContainerView: UIView = { + let shadowView = UIView() + shadowView.layer.shadowColor = UIColor.black.cgColor + shadowView.layer.shadowOpacity = 0.25 + shadowView.layer.shadowRadius = 2 + shadowView.layer.shadowOffset = CGSize(width: 0, height: 2) + return shadowView + }() + + private let markingCircularBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = DesignSystemColor.main03 + view.layer.cornerRadius = Metrics.markingSize * 0.5 + return view + }() + + private let textStackView: UIStackView = { + let stackView = UIStackView() + stackView.alignment = .leading + stackView.axis = .vertical + stackView.spacing = 2 + return stackView + }() + + private let imageIncludeStackView: UIStackView = { + let stackView = UIStackView() + stackView.spacing = 10 + stackView.alignment = .center + return stackView + }() + + private let wholeStackView: UIStackView = { + let stackView = UIStackView() + stackView.distribution = .equalSpacing + stackView.alignment = .top + return stackView + }() + + private let containerView: UIView = { + let view = UIView() + view.layer.cornerRadius = 8 + view.backgroundColor = DesignSystemColor.secondaryBackGround + return view + }() + + // MARK: - Initializations + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayouts() + setupConstraints() + setupStyles() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupLayouts() + setupConstraints() + setupStyles() + } + + private func setupLayouts() { + contentView.addSubview(containerView) + containerView.addSubview(wholeStackView) + + wholeStackView.addArrangedSubview(imageIncludeStackView) + wholeStackView.addArrangedSubview(markingCircularContainerView) + + markingCircularContainerView.addSubview(markingCircularBackgroundView) + + imageIncludeStackView.addArrangedSubview(profileImageView) + imageIncludeStackView.addArrangedSubview(textStackView) + + textStackView.addArrangedSubview(nicknameLabel) + textStackView.addArrangedSubview(distanceLabel) + } + + private func setupConstraints() { + containerView.translatesAutoresizingMaskIntoConstraints = false + wholeStackView.translatesAutoresizingMaskIntoConstraints = false + profileImageView.translatesAutoresizingMaskIntoConstraints = false + markingCircularContainerView.translatesAutoresizingMaskIntoConstraints = false + markingCircularBackgroundView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate( + [ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + + wholeStackView.topAnchor.constraint( + equalTo: containerView.topAnchor, + constant: Metrics.wholeStackViewEdge + ), + wholeStackView.bottomAnchor.constraint( + equalTo: containerView.bottomAnchor, + constant: -Metrics.wholeStackViewEdge + ), + wholeStackView.leadingAnchor.constraint( + equalTo: containerView.leadingAnchor, + constant: Metrics.wholeStackViewEdge + ), + wholeStackView.trailingAnchor.constraint( + equalTo: containerView.trailingAnchor, + constant: -Metrics.wholeStackViewEdge + ), + + profileImageView.widthAnchor.constraint(equalToConstant: Metrics.profileImageSize), + profileImageView.heightAnchor.constraint(equalToConstant: Metrics.profileImageSize), + + markingCircularContainerView.widthAnchor.constraint(equalToConstant: Metrics.markingSize), + markingCircularContainerView.heightAnchor.constraint(equalToConstant: Metrics.markingSize), + + markingCircularBackgroundView.topAnchor.constraint(equalTo: markingCircularContainerView.topAnchor), + markingCircularBackgroundView.bottomAnchor.constraint(equalTo: markingCircularContainerView.bottomAnchor), + markingCircularBackgroundView.leadingAnchor.constraint(equalTo: markingCircularContainerView.leadingAnchor), + markingCircularBackgroundView.trailingAnchor.constraint(equalTo: markingCircularContainerView.trailingAnchor), + ] + ) + } + + private func setupStyles() { + contentView.layer.shadowColor = UIColor.black.cgColor + contentView.layer.shadowOpacity = 0.25 + contentView.layer.shadowRadius = 2 + contentView.layer.shadowOffset = CGSize(width: 0, height: 2) + contentView.backgroundColor = .clear + } + + // MARK: Internal + + func configure(with imageName: String) { + profileImageView.image = UIImage(systemName: imageName) + } +} + +// MARK: ParticipantsCollectionViewCell.Metrics + +private extension ParticipantsCollectionViewCell { + enum Metrics { + static let profileImageSize: CGFloat = 64 + static let wholeStackViewEdge: CGFloat = 10 + static let markingSize: CGFloat = 12 + } + + enum Strings { + static let nicknameAccessibilityHint = "닉네임" + static let workoutDistanceAccessibilityHint = "운동한 거리" + } +} diff --git a/iOS/Projects/Features/Record/Sources/WorkoutSummary/WorkoutSummaryViewController.swift b/iOS/Projects/Features/Record/Sources/WorkoutSummary/WorkoutSummaryViewController.swift new file mode 100644 index 00000000..e3ec2c50 --- /dev/null +++ b/iOS/Projects/Features/Record/Sources/WorkoutSummary/WorkoutSummaryViewController.swift @@ -0,0 +1,237 @@ +// +// WorkoutSummaryViewController.swift +// RecordFeature +// +// Created by 홍승현 on 11/16/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine +import DesignSystem +import UIKit + +// MARK: - WorkoutSummaryViewController + +public final class WorkoutSummaryViewController: UIViewController { + // MARK: Properties + + private let viewModel: WorkoutSummaryViewModelRepresentable + + private var participantsDataSource: ParticipantsDataSource? + + private var subscriptions: Set = [] + + private let endWorkoutSubject: PassthroughSubject = .init() + + // MARK: UI Components + + private let recordTimerLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .largeTitle) + label.text = "0분 0초" + return label + }() + + private lazy var endWorkoutButton: UIButton = { + let button = UIButton(configuration: .mainCircularEnabled(title: "종료")) + button.configuration?.font = .preferredFont(forTextStyle: .largeTitle, with: .traitBold) + button.accessibilityHint = "운동을 종료합니다." + button.addAction( + UIAction { [weak self] _ in + self?.endWorkoutSubject.send(()) + }, + for: .touchUpInside + ) + return button + }() + + private lazy var participantsCollectionView: UICollectionView = { + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: createCompositionalLayout() + ) + collectionView.backgroundColor = DesignSystemColor.primaryBackGround + collectionView.showsVerticalScrollIndicator = false + return collectionView + }() + + // MARK: Initializations + + public init(viewModel: WorkoutSummaryViewModelRepresentable) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Life Cycles + + override public func viewDidLoad() { + super.viewDidLoad() + setupLayouts() + setupConstraints() + setupStyles() + bind() + generateDataSources() + setupInitialSnapshots() + } + + // MARK: Configuration + + private func setupLayouts() { + view.addSubview(recordTimerLabel) + view.addSubview(participantsCollectionView) + view.addSubview(endWorkoutButton) + } + + private func setupConstraints() { + let safeArea = view.safeAreaLayoutGuide + recordTimerLabel.translatesAutoresizingMaskIntoConstraints = false + endWorkoutButton.translatesAutoresizingMaskIntoConstraints = false + participantsCollectionView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate( + [ + recordTimerLabel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: Metrics.horizontal), + recordTimerLabel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -Metrics.horizontal), + recordTimerLabel.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: Metrics.recordTimerLabelTop), + + participantsCollectionView.topAnchor.constraint( + equalTo: recordTimerLabel.bottomAnchor, + constant: Metrics.collectionViewTop + ), + participantsCollectionView.leadingAnchor.constraint( + equalTo: safeArea.leadingAnchor, + constant: Metrics.horizontal + ), + participantsCollectionView.trailingAnchor.constraint( + equalTo: safeArea.trailingAnchor, + constant: -Metrics.horizontal + ), + participantsCollectionView.bottomAnchor.constraint( + equalTo: endWorkoutButton.topAnchor, + constant: -Metrics.collectionViewBottom + ), + + endWorkoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + endWorkoutButton.widthAnchor.constraint(equalToConstant: Metrics.endingWorkoutButtonSize), + endWorkoutButton.heightAnchor.constraint(equalToConstant: Metrics.endingWorkoutButtonSize), + endWorkoutButton.bottomAnchor.constraint( + equalTo: safeArea.bottomAnchor, + constant: -Metrics.endingWorkoutButtonBottom + ), + ] + ) + } + + private func setupStyles() { + view.backgroundColor = DesignSystemColor.primaryBackGround + } + + private func bind() { + let output = viewModel.transform(input: .init(endWorkoutPublisher: endWorkoutSubject.eraseToAnyPublisher())) + output.sink { state in + switch state { + case .idle: + break + } + } + .store(in: &subscriptions) + } + + private func createCompositionalLayout() -> UICollectionViewCompositionalLayout { + let item = NSCollectionLayoutItem( + layoutSize: .init( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(Metrics.collectionViewCellHeight) + ) + ) + item.edgeSpacing = .init( + leading: nil, + top: .fixed(Metrics.collectionViewItemSpacing), + trailing: nil, + bottom: .fixed(Metrics.collectionViewItemSpacing) + ) + + let group = NSCollectionLayoutGroup.vertical( + layoutSize: .init( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(468) // Item 높이와 수에 따라 정해지기에 의미없는 값(468)을 넣음 + ), + subitems: [item] + ) + + let section = NSCollectionLayoutSection(group: group) + + return UICollectionViewCompositionalLayout(section: section) + } + + /// DataSource를 생성합니다. + private func generateDataSources() { + let cellRegistration = ParticipantsCellRegistration { cell, _, itemIdentifier in + cell.configure(with: itemIdentifier) + } + + participantsDataSource = ParticipantsDataSource( + collectionView: participantsCollectionView + ) { collectionView, indexPath, itemIdentifier in + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier) + } + } + + private func setupInitialSnapshots() { + guard let participantsDataSource else { return } + var snapshot = ParticipantsSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems( + [ + "house", + "square.and.arrow.up", + "pencil.circle", + "pencil.and.outline", + "pencil.tip.crop.circle.badge.minus.fill", + ] + ) + + participantsDataSource.apply(snapshot) + } +} + +// MARK: WorkoutSummaryViewController.Metrics + +private extension WorkoutSummaryViewController { + enum Metrics { + static let recordTimerLabelTop: CGFloat = 12 + static let collectionViewTop: CGFloat = 12 + static let collectionViewBottom: CGFloat = 44 + static let collectionViewItemSpacing: CGFloat = 6 + static let horizontal: CGFloat = 36 + static let endingWorkoutButtonBottom: CGFloat = 32 + + static let endingWorkoutButtonSize: CGFloat = 150 + static let collectionViewCellHeight: CGFloat = 84 + } +} + +// MARK: - Diffable DataSources Options + +private extension WorkoutSummaryViewController { + typealias ParticipantsCellRegistration = UICollectionView.CellRegistration + typealias ParticipantsDataSource = UICollectionViewDiffableDataSource + typealias ParticipantsSnapshot = NSDiffableDataSourceSnapshot + + // TODO: API가 정해진 뒤 Item 설정 필요 + typealias Item = String + + enum Section { + case main + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, xrOS 1.0, *) +#Preview { + WorkoutSummaryViewController(viewModel: WorkoutSummaryViewModel()) +} diff --git a/iOS/Projects/Features/Record/Sources/WorkoutSummary/WorkoutSummaryViewModel.swift b/iOS/Projects/Features/Record/Sources/WorkoutSummary/WorkoutSummaryViewModel.swift new file mode 100644 index 00000000..790e40ba --- /dev/null +++ b/iOS/Projects/Features/Record/Sources/WorkoutSummary/WorkoutSummaryViewModel.swift @@ -0,0 +1,60 @@ +// +// WorkoutSummaryViewModel.swift +// RecordFeature +// +// Created by 홍승현 on 11/16/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Combine + +// MARK: - WorkoutSummaryViewModelInput + +public struct WorkoutSummaryViewModelInput { + let endWorkoutPublisher: AnyPublisher +} + +public typealias WorkoutSummaryViewModelOutput = AnyPublisher + +// MARK: - WorkoutSummaryState + +public enum WorkoutSummaryState { + case idle +} + +// MARK: - WorkoutSummaryViewModelRepresentable + +public protocol WorkoutSummaryViewModelRepresentable { + func transform(input: WorkoutSummaryViewModelInput) -> WorkoutSummaryViewModelOutput +} + +// MARK: - WorkoutSummaryViewModel + +public final class WorkoutSummaryViewModel { + // MARK: Properties + + private var subscriptions: Set = [] + + // MARK: Initializations + + public init() {} +} + +// MARK: WorkoutSummaryViewModelRepresentable + +extension WorkoutSummaryViewModel: WorkoutSummaryViewModelRepresentable { + public func transform(input: WorkoutSummaryViewModelInput) -> WorkoutSummaryViewModelOutput { + for subscription in subscriptions { + subscription.cancel() + } + subscriptions.removeAll() + + input.endWorkoutPublisher + .sink {} + .store(in: &subscriptions) + + let initialState: WorkoutSummaryViewModelOutput = Just(.idle).eraseToAnyPublisher() + + return initialState + } +} diff --git a/iOS/graph.png b/iOS/graph.png index 08e465d7..e3b61cf9 100644 Binary files a/iOS/graph.png and b/iOS/graph.png differ