diff --git a/myot-si-ya.swiftpm/Utils/Extension/Color+.swift b/myot-si-ya.swiftpm/Utils/Extension/Color+.swift index 3358469..e652831 100644 --- a/myot-si-ya.swiftpm/Utils/Extension/Color+.swift +++ b/myot-si-ya.swiftpm/Utils/Extension/Color+.swift @@ -7,16 +7,6 @@ import SwiftUI -extension Color { - static let bg = Color("bg") - static let gray1 = Color("gray1") - static let gray2 = Color("gray2") - static let gray3 = Color("gray3") - static let gray4 = Color("gray4") - static let gray5 = Color("gray5") - static let quaternary = Color("quaternary") -} - extension Color { init(hex: String) { let scanner = Scanner(string: hex) diff --git a/myot-si-ya.swiftpm/Utils/Extension/SwiftUI/File.swift b/myot-si-ya.swiftpm/Utils/Extension/SwiftUI/AnyTransition+.swift similarity index 90% rename from myot-si-ya.swiftpm/Utils/Extension/SwiftUI/File.swift rename to myot-si-ya.swiftpm/Utils/Extension/SwiftUI/AnyTransition+.swift index b5bd075..03f6cd6 100644 --- a/myot-si-ya.swiftpm/Utils/Extension/SwiftUI/File.swift +++ b/myot-si-ya.swiftpm/Utils/Extension/SwiftUI/AnyTransition+.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// AnyTransition+.swift +// // // Created by 남유성 on 2/14/24. // diff --git a/myot-si-ya/myot-si-ya/View/Components/Button/ControlButton.swift b/myot-si-ya/myot-si-ya/View/Components/Button/ControlButton.swift new file mode 100644 index 0000000..fafdddd --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Button/ControlButton.swift @@ -0,0 +1,43 @@ +// +// ControlButton.swift +// +// +// Created by 남유성 on 2/5/24. +// + +import SwiftUI + +struct ControlButton: View { + + @State var isToggled: Bool = false + + let icon: String + let toggleIcon: String? + let tintColor: Color = .secondary + let bgColor: Color = .quaternary + let iconSize: CGFloat = 32 + let btnSize: CGSize = CGSize(width: 60, height: 60) + let action: () -> Void + + init(icon: String, toggleIcon: String? = nil, action: @escaping () -> Void) { + self.icon = icon + self.toggleIcon = toggleIcon + self.action = action + } + + var body: some View { + Button { + action() + if toggleIcon != nil { + isToggled.toggle() + } + } label: { + Image(systemName: (toggleIcon == nil || !isToggled) ? icon : toggleIcon!) + .font(.system(size: iconSize, weight: .regular)) + .foregroundStyle(tintColor) + } + .frame(width: btnSize.width, height: btnSize.height) + .background(bgColor) + .clipShape(Circle()) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Components/Button/IconButton.swift b/myot-si-ya/myot-si-ya/View/Components/Button/IconButton.swift new file mode 100644 index 0000000..079841e --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Button/IconButton.swift @@ -0,0 +1,64 @@ +// +// IconButton.swift +// TokTakTokTak +// +// Created by 남유성 on 2/1/24. +// + +import SwiftUI + +struct IconButton: View { + + @State private var isToggled: Bool = false + + private var name: String = "" + private var nameForToggle: String? + private var contentSize: CGFloat + private var btnSize: CGSize + private var content: String? + + private var action: () -> Void + + public init( + _ name: String = "", + _ nameForToggle: String? = nil, + contentSize: CGFloat = 32, + btnSize: CGSize = CGSize(width: 48, height: 48), + action: @escaping () -> Void) { + + self.name = name + self.nameForToggle = nameForToggle + self.contentSize = contentSize + self.btnSize = btnSize + self.action = action + } + + public init( + text: String, + contentSize: CGFloat = 32, + btnSize: CGSize = CGSize(width: 48, height: 48), + action: @escaping () -> Void) { + + self.content = text + self.contentSize = contentSize + self.btnSize = btnSize + self.action = action + } + + public var body: some View { + Button { + action() + isToggled.toggle() + } label: { + if let content = content { + Text(content) + .font(.system(size: contentSize, weight: .thin)) + .frame(width: btnSize.width, height: btnSize.height) + } else { + Image(systemName: nameForToggle == nil ? name : isToggled ? nameForToggle! : name) + .font(.system(size: contentSize, weight: .thin)) + .frame(width: btnSize.width, height: btnSize.height) + } + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Components/ClearBgView.swift b/myot-si-ya/myot-si-ya/View/Components/ClearBgView.swift new file mode 100644 index 0000000..f503eaa --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/ClearBgView.swift @@ -0,0 +1,23 @@ +// +// ClearBgView.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct ClearBgView: UIViewRepresentable { + + func makeUIView(context: Context) -> some UIView { + let view = UIView() + + DispatchQueue.main.async { + view.superview?.superview?.backgroundColor = .clear + } + + return view + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} +} diff --git a/myot-si-ya/myot-si-ya/View/Components/KoreanPronsView.swift b/myot-si-ya/myot-si-ya/View/Components/KoreanPronsView.swift new file mode 100644 index 0000000..cbe105f --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/KoreanPronsView.swift @@ -0,0 +1,89 @@ +// +// KoreanPronsView.swift +// +// +// Created by 남유성 on 2/2/24. +// + +import SwiftUI + +struct KoreanPronsListView: View { + + private let text: String + private let detail: String + private let prons: String + private let detail2: String? + private let prons2: String? + private let textWidth: CGFloat + private let pronsWidth: CGFloat + + init( + _ text: String, + _ detail: String, + _ prons: String, + _ detail2: String? = nil, + _ prons2: String? = nil, + textWidth: CGFloat = 82, + pronsWidth: CGFloat = 140 + ) { + self.text = text + self.detail = detail + self.prons = prons + self.detail2 = detail2 + self.prons2 = prons2 + self.textWidth = textWidth + self.pronsWidth = pronsWidth + } + + var body: some View { + HStack { + HStack { + Spacer() + Text(text) + } + .frame(width: textWidth) + + Rectangle() + .frame(width: 1, height: 12) + .background(Color.gray3) + .padding([.leading, .trailing], 8) + + HStack { + KoreanPronsView(text: detail, prons: prons) + Spacer() + } + .frame(width: pronsWidth) + + if detail2 != nil && prons2 != nil { + Text("/") + .aggro(.light, size: 17) + KoreanPronsView(text: detail2!, prons: prons2!) + } + } + } +} + +struct KoreanPronsView: View { + + private let text: String + private let prons: String + + init(text: String, prons: String) { + self.text = text + self.prons = prons + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Text(text) + .aggro(.light, size: 17) + .foregroundStyle(.primary) + Text("[\(prons)]") + .aggro(.light, size: 15) + .foregroundStyle(.secondary) + } + } + } +} + diff --git a/myot-si-ya/myot-si-ya/View/Components/Picker/CustomUIPickerView.swift b/myot-si-ya/myot-si-ya/View/Components/Picker/CustomUIPickerView.swift new file mode 100644 index 0000000..379ef9f --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Picker/CustomUIPickerView.swift @@ -0,0 +1,22 @@ +// +// CustomUIPickerView.swift +// +// +// Created by 남유성 on 2/5/24. +// + +import UIKit + +class CustomUIPickerView: UIPickerView { + + let type: PickerType + + init(type: PickerType) { + self.type = type + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/myot-si-ya/myot-si-ya/View/Components/Picker/MultiAlarmPickerView.swift b/myot-si-ya/myot-si-ya/View/Components/Picker/MultiAlarmPickerView.swift new file mode 100644 index 0000000..2919f24 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Picker/MultiAlarmPickerView.swift @@ -0,0 +1,82 @@ +// +// MultiAlarmPickerView.swift +// +// +// Created by 남유성 on 2/7/24. +// + +import SwiftUI + +enum TimeSection: CaseIterable { + case am + case pm + + var korean: String { + switch self { + case .am: + return "오전" + case .pm: + return "오후" + } + } +} + +struct MultiAlarmPickerView: View { + + @Binding var selectedAmPm: Int + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + + let type: PickerType + + let sections = TimeSection.allCases.map { $0.korean } + let hours = Array(0..<12).map { + if $0 == 0 { + return "열두" + } + return $0.nativeKoreanTime! + } + let minutes = Array(0..<60).map { $0.sinoKoreanTime! } + + var body: some View { + ZStack { + HStack(spacing: type == .large ? 24 : 16) { + TimePickerView( + selectedItem: $selectedAmPm, + items: sections, + isLoop: false, + type: type + ) + TimePickerView( + selectedItem: $selectedHour, + items: hours, + isLoop: true, + type: type + ) + Text("시") + TimePickerView( + selectedItem: $selectedMinute, + items: minutes, + isLoop: true, + type: type + ) + Text("분") + } + .aggro(.medium, size: type == .large ? 40 : 24) + + VStack { // prevent touch + Color.bg + .contentShape(Rectangle()) + .frame(height: 42) + + Spacer() + + Color.bg + .contentShape(Rectangle()) + .frame(height: 42) + } + .frame(height: type == .large ? 84 * 5 : 48 * 6) + } + .background(Color.bg) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Components/Picker/MultiTimerPickerView.swift b/myot-si-ya/myot-si-ya/View/Components/Picker/MultiTimerPickerView.swift new file mode 100644 index 0000000..2e9a963 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Picker/MultiTimerPickerView.swift @@ -0,0 +1,64 @@ +// +// MultiTimerPickerView.swift +// +// +// Created by 남유성 on 2/5/24. +// + +import SwiftUI + +struct MultiTimerPickerView: View { + + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + @Binding var selectedSecond: Int + + let type: PickerType + + let hours = Array(0..<12).map { $0.nativeKoreanTime! } + let minutes = Array(0..<60).map { $0.sinoKoreanTime! } + let seconds = Array(0..<60).map { $0.sinoKoreanTime! } + + var body: some View { + ZStack { + HStack(spacing: type == .large ? 24 : 16) { + TimePickerView( + selectedItem: $selectedHour, + items: hours, + isLoop: true, + type: type + ) + Text("시간") + TimePickerView( + selectedItem: $selectedMinute, + items: minutes, + isLoop: true, + type: type + ) + Text("분") + TimePickerView( + selectedItem: $selectedSecond, + items: seconds, + isLoop: true, + type: type + ) + Text("초") + } + .aggro(.medium, size: type == .large ? 40 : 24) + + VStack { // prevent touch + Color.bg + .contentShape(Rectangle()) + .frame(height: 42) + + Spacer() + + Color.bg + .contentShape(Rectangle()) + .frame(height: 42) + } + .frame(height: type == .large ? 84 * 5 : 48 * 6) + } + .background(Color.bg) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Components/Picker/TimePickerView.swift b/myot-si-ya/myot-si-ya/View/Components/Picker/TimePickerView.swift new file mode 100644 index 0000000..1a9c00b --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Picker/TimePickerView.swift @@ -0,0 +1,136 @@ +// +// KoreanTimePickerView.swift +// +// +// Created by 남유성 on 2/5/24. +// + +import SwiftUI + +enum PickerType { + case large + case small + + var fontSize: CGFloat { + switch self { + case .small: return 40 + case .large: return 70 + } + } + + var height: CGFloat { + switch self { + case .small: return 48 + case .large: return 84 + } + } +} + +struct TimePickerView: UIViewRepresentable { + + @Binding var selectedItem: Int + @State var section = 1000 + @State var lastSelectedRow = 0 + + let items: [T] + let isLoop: Bool + let type: PickerType + + func makeUIView(context: Context) -> UIPickerView { + let pickerView = CustomUIPickerView(type: type) + pickerView.dataSource = context.coordinator + pickerView.delegate = context.coordinator + switch type { + case .large: + pickerView.frame.size = CGSize(width: 228, height: 84 * 4) + case .small: + pickerView.frame.size = CGSize(width: 138, height: 48 * 4) + } + return pickerView + } + + func updateUIView(_ pickerView: UIPickerView, context: Context) { + if isLoop { + pickerView.selectRow(selectedItem + section * items.count, inComponent: 0, animated: true) + } else { + pickerView.selectRow(selectedItem, inComponent: 0, animated: true) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate { + var parent: TimePickerView + + init(_ pickerView: TimePickerView) { + self.parent = pickerView + } + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + if parent.isLoop { + return parent.items.count * 10000 + } + + return parent.items.count + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + let actualRow = row % parent.items.count + + return "\(parent.items[actualRow])" + } + + func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView { + + let actualRow = row % parent.items.count + let label: UILabel + + if let view = view as? UILabel { + label = view + } else { + label = UILabel() + } + + pickerView.subviews.forEach { + $0.backgroundColor = .clear + $0.alpha = 1 + } + + label.text = "\(parent.items[actualRow])" + label.textColor = .white + label.textAlignment = .right + label.font = UIFont(name: Aggro.bold.rawValue, size: parent.type.fontSize) + label.backgroundColor = .clear + + return label + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + let actualRow = row % parent.items.count + let lastSection = parent.lastSelectedRow / parent.items.count + let curSection = row / parent.items.count + + if row > parent.lastSelectedRow { // down + if lastSection != curSection { + parent.section = curSection + } + } else if row < parent.lastSelectedRow { // up + if lastSection != curSection { + parent.section -= abs(lastSection - curSection) + } + } + parent.lastSelectedRow = row + parent.selectedItem = actualRow + } + + func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat { + return parent.type.height + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Components/Slider/HorizontalSlider.swift b/myot-si-ya/myot-si-ya/View/Components/Slider/HorizontalSlider.swift new file mode 100644 index 0000000..c00573c --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Slider/HorizontalSlider.swift @@ -0,0 +1,48 @@ +// +// HorizontalSlider.swift +// +// +// Created by 남유성 on 2/3/24. +// + +import SwiftUI + +struct HorizontalSlider: View { + + @Binding var value: Double + var onChange: () -> Void + var onEnd: () -> Void + + var body: some View { + GeometryReader { gr in + let thumbSize = gr.size.height + let maxX = gr.size.width - thumbSize + + ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) { + + RoundedRectangle(cornerRadius: 12) + .foregroundColor(.quaternary) + .frame(width: gr.size.height, height: thumbSize) + + + RoundedRectangle(cornerRadius: 12) + .frame(width: thumbSize + value * maxX, height: thumbSize) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { v in + let posX = v.location.x + value = max(0, min(maxX, posX)) / maxX + onChange() + } + .onEnded { v in + let posX = v.location.x + value = max(0, min(maxX, posX)) / maxX + onEnd() + } + ) + + } + } +} + diff --git a/myot-si-ya/myot-si-ya/View/Components/Slider/VerticalSlider.swift b/myot-si-ya/myot-si-ya/View/Components/Slider/VerticalSlider.swift new file mode 100644 index 0000000..3eb1f06 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Components/Slider/VerticalSlider.swift @@ -0,0 +1,47 @@ +// +// VerticalSlider.swift +// TokTakTokTak +// +// Created by 남유성 on 2/2/24. +// + +import SwiftUI + +struct VerticalSlider: View { + + @Binding var value: Double + var onChange: () -> Void + var onEnd: () -> Void + + var body: some View { + GeometryReader { gr in + let thumbSize = gr.size.width + let maxY = gr.size.height - thumbSize + + ZStack(alignment: Alignment(horizontal: .center, vertical: .bottom)) { + + RoundedRectangle(cornerRadius: 12) + .foregroundColor(.quaternary) + .frame(width: thumbSize, height: gr.size.height) + + + RoundedRectangle(cornerRadius: 12) + .frame(width: thumbSize, height: thumbSize + value * maxY) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { v in + let posY = maxY - v.location.y + value = max(0, min(maxY, posY)) / maxY + onChange() + } + .onEnded { v in + let posY = maxY - v.location.y + value = max(0, min(maxY, posY)) / maxY + onEnd() + } + ) + + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainClockView.swift b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainClockView.swift new file mode 100644 index 0000000..4417b15 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainClockView.swift @@ -0,0 +1,65 @@ +// +// MainClockView.swift +// TokTakTokTak +// +// Created by 남유성 on 2/3/24. +// + +import SwiftUI + +struct AboutMainClockView: View { + + @Binding var currentTime: Date + @Binding var brightness: Double + @Binding var isMuted: Bool + + @State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + let player: AudioService + + var body: some View { + VStack(alignment: .trailing, spacing: -24) { + Text("\(currentTime.toKoreanAmPm())") + HStack(spacing: 0) { + VStack(alignment: .trailing, spacing: -24) { + Text(hour()) + Text(minute()) + Text(second()) + } + VStack(alignment: .trailing, spacing: -24) { + Text("시") + Text("분") + Text("초") + } + .foregroundStyle(brightness < 0.2 ? .primary : .tertiary) + .animation(.easeInOut, value: brightness) + } + .onReceive(timer) { + currentTime = $0 + if !isMuted { + player.playAudio(fileName: "clock",playCount: 1) + } + } + } + .aggro(.bold, size: 130) + .foregroundColor(.primary) + } + + fileprivate func hour() -> String { + let hour = Calendar.current.component(.hour, from: currentTime) + + if hour == 0 { + return "열두" + } + + return currentTime.toKoreanHours() + } + + fileprivate func minute() -> String { + currentTime.toKoreanMinutes() + } + + fileprivate func second() -> String { + currentTime.toKoreanSeconds() + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainControlView.swift b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainControlView.swift new file mode 100644 index 0000000..2405a18 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainControlView.swift @@ -0,0 +1,130 @@ +// +// MainControlView.swift +// TokTakTokTak +// +// Created by 남유성 on 2/3/24. +// + +import SwiftUI +import Combine + +struct AboutMainControlView: View { + + @Binding var isScreenMode: Bool + @Binding var isUpdatingBrightness: Bool + @Binding var brightness: Double + + @Binding var timerCancellable: AnyCancellable? + + @Binding var isPortrait: Bool + + var body: some View { + if isPortrait { + HStack(spacing: 16) { + IconButton(Icon.originalSize) { + withAnimation { + isScreenMode = false + } + isUpdatingBrightness = false + UIApplication.shared.isIdleTimerDisabled = false + } + .animation(.easeInOut, value: brightness) + .foregroundStyle(brightness < 0.2 ? .primary : .tertiary) + + IconButton(Icon.brightness) { + if isUpdatingBrightness { + isUpdatingBrightness = false + } else { + isUpdatingBrightness = true + setTimer() + } + } + .animation(.easeInOut, value: brightness) + .foregroundStyle(isUpdatingBrightness || brightness < 0.2 ? .primary : .tertiary) + + if isUpdatingBrightness { + VStack { + HorizontalSlider( + value: $brightness, + onChange: { + timerCancellable?.cancel() + UIScreen.main.brightness = CGFloat(brightness) + }, + onEnd: { + setTimer() + } + ) + .frame(width: 340, height: 40) + .transition(.opacity) + } + } + } + .transition(.opacity) + } else { + VStack { + if isUpdatingBrightness { + HStack { + Spacer() + VerticalSlider( + value: $brightness, + onChange: { + timerCancellable?.cancel() + UIScreen.main.brightness = CGFloat(brightness) + }, + onEnd: { + setTimer() + } + ) + .frame(width: 40, height: 340) + .transition(.opacity) + + Spacer() + .frame(width: 4) + } + + Spacer() + .frame(height: 28) + } + + HStack(spacing: 16) { + IconButton(Icon.originalSize) { + withAnimation { + isScreenMode = false + } + isUpdatingBrightness = false + UIApplication.shared.isIdleTimerDisabled = false + } + .animation(.easeInOut, value: brightness) + .foregroundStyle(brightness < 0.2 ? .primary : .tertiary) + + IconButton(Icon.brightness) { + withAnimation { + if isUpdatingBrightness { + isUpdatingBrightness = false + } else { + isUpdatingBrightness = true + setTimer() + } + } + } + .animation(.easeInOut, value: brightness) + .foregroundStyle(isUpdatingBrightness || brightness < 0.2 ? .primary : .tertiary) + } + .transition(.opacity) + } + .frame(width: 112, alignment: .trailing) + } + } + + func setTimer() { + timerCancellable?.cancel() + + timerCancellable = Timer.publish(every: 3, on: .main, in: .common) + .autoconnect() + .sink { _ in + withAnimation { + self.isUpdatingBrightness = false + } + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainDetailView.swift b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainDetailView.swift new file mode 100644 index 0000000..d1f1401 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainDetailView.swift @@ -0,0 +1,76 @@ +// +// MainDetailView.swift +// TokTakTokTak +// +// Created by 남유성 on 2/3/24. +// + +import SwiftUI + +struct AboutMainDetailView: View { + + @Binding var currentTime: Date + @Binding var isScreenMode: Bool + @Binding var isPresentingSheet: Bool + @Binding var isMuted: Bool + + let player: AudioService + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Hangeul Clock: 몇시야?") + .aggro(.bold, size: 32) + Text("is a Hangeul clock app that marks\nthe current time in Korean.") + .aggro(.light, size: 17) + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("몇시야?") + .aggro(.medium, size: 15) + Text("[myot-si-ya]") + .foregroundStyle(.tertiary) + } + Text("means what time is it?\nIt is a phrase in Korean asking for the exact time.") + } + .aggro(.light, size: 15) + + VStack(alignment: .leading) { + Text("current time is") + .aggro(.light, size: 17) + Text(currentTime.toFormat("h:mm:ss a")) + .aggro(.medium, size: 20) + } + + HStack(spacing: 16) { + if isMuted { + IconButton(Icon.soundOff) { + isMuted = false + player.unmute() + } + } else { + IconButton(Icon.sound) { + isMuted = true + player.mute() + } + } + IconButton(Icon.fullSize) { + withAnimation { + isScreenMode = true + UIApplication.shared.isIdleTimerDisabled = true + } + } + IconButton(text: "Aa") { + isPresentingSheet = true + } + .fullScreenCover(isPresented: $isPresentingSheet) { + AboutMainInfoSheet() + .clearBg() + } + } + .foregroundStyle(.primary) + } + .transition(.opacity) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainInfoSheet.swift b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainInfoSheet.swift new file mode 100644 index 0000000..93cd693 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainInfoSheet.swift @@ -0,0 +1,62 @@ +// +// KoreanSheetView.swift +// TokTakTokTak +// +// Created by 남유성 on 2/2/24. +// + +import SwiftUI + +struct AboutMainInfoSheet: View { + + @Environment(\.dismiss) private var dismiss + + + @State private var date: Date = Date() + @State private var offset: CGSize = .zero + @State private var alpha: Double = 1.0 + + private let speechService = SpeechService() + private let dThreshold: Double = 200 + private let vThreshold: Double = 200 + + var body: some View { + GeometryReader { gr in + TabView { + AboutSheetFirstPage(speechService) + AboutSheetSecondPage(date: $date, speechService: speechService) + AboutSheetThirdPage(date: $date, speechService: speechService) + } + .tabViewStyle(.page) + .offset(offset) + .gesture( + DragGesture() + .onChanged { v in + let dy = v.location.y - v.startLocation.y + let y = v.translation.height + + if dy > 0 { + offset = CGSize(width: 0, height: y) + alpha = dThreshold / (abs(y) * 2.0) + } + } + .onEnded { v in + let dy = v.location.y - v.startLocation.y + let vy = v.velocity.height + + if dy > dThreshold || vy > vThreshold { + dismiss() + } else { + withAnimation { + offset = .zero + alpha = 1.0 + } + } + } + ) + .background(Color.bg) + .opacity(alpha) + .ignoresSafeArea() + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainView.swift b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainView.swift new file mode 100644 index 0000000..58f5ff0 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/AboutMainView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import UIKit +import Combine + +struct AboutMainView: View { + @State private var currentTime = Date() + @State private var isMuted: Bool = false + @State private var isPresentingSheet: Bool = false + @State private var isScreenMode: Bool = false + @State private var isUpdatingBrightness: Bool = false + @State private var isPortrait: Bool = false + + @State private var timerCancellable: AnyCancellable? + @State private var brightness: Double = Double(UIScreen.main.brightness) + + private let player = AudioService() + + var body: some View { + ZStack { + GeometryReader { gr in + if gr.size.width < gr.size.height { + VStack { + if isScreenMode { + HStack { + AboutMainControlView( + isScreenMode: $isScreenMode, + isUpdatingBrightness: $isUpdatingBrightness, + brightness: $brightness, + timerCancellable: $timerCancellable, + isPortrait: $isPortrait + ) + .padding([.top, .leading, .bottom], 100) + Spacer() + } + } else { + HStack { + AboutMainDetailView( + currentTime: $currentTime, + isScreenMode: $isScreenMode, + isPresentingSheet: $isPresentingSheet, + isMuted: $isMuted, + player: player + ) + .padding([.top, .leading], 100) + Spacer() + } + } + + Spacer() + + HStack { + Spacer() + AboutMainClockView( + currentTime: $currentTime, + brightness: $brightness, + isMuted: $isMuted, + player: player + ) + .padding([.bottom, .trailing], 100) + } + } + } else { + HStack { + if isScreenMode { + VStack { + Spacer() + AboutMainControlView( + isScreenMode: $isScreenMode, + isUpdatingBrightness: $isUpdatingBrightness, + brightness: $brightness, + timerCancellable: $timerCancellable, + isPortrait: $isPortrait + ) + .padding([.leading, .bottom], 100) + } + } else { + VStack { + AboutMainDetailView( + currentTime: $currentTime, + isScreenMode: $isScreenMode, + isPresentingSheet: $isPresentingSheet, + isMuted: $isMuted, + player: player + ) + .padding([.top, .leading], 100) + Spacer() + } + } + + Spacer() + + VStack { + Spacer() + AboutMainClockView( + currentTime: $currentTime, + brightness: $brightness, + isMuted: $isMuted, + player: player + ) + .padding([.top, .bottom, .trailing], 100) + } + } + } + } + } + .edgesIgnoringSafeArea(.all) + .background(Color.bg) + .onAppear { + isMuted = false + } + .onDisappear { + isMuted = true + } + .onRotate { newOrientation in + switch newOrientation { + case .portrait, .portraitUpsideDown: + isPortrait = true + case .landscapeLeft, .landscapeRight: + isPortrait = false + default: + break + } + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetFirstPage.swift b/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetFirstPage.swift new file mode 100644 index 0000000..3c756c3 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetFirstPage.swift @@ -0,0 +1,63 @@ +// +// AboutSheetFirstPage.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct AboutSheetFirstPage: View { + + let speechService: SpeechService + + init(_ speechService: SpeechService) { + self.speechService = speechService + } + + var body: some View { + VStack(alignment: .leading) { + Text("What time is it?") + .aggro(.bold, size: 32) + + Spacer().frame(height: 32) + + VStack(alignment: .leading, spacing: -12) { + Text("In korean, you can say") + .aggro(.light, size: 20) + + HStack(spacing: 4) { + Text("몇시야?") + .aggro(.bold, size: 32) + Text("[myot-si-ya]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + Spacer() + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean("몇시야?") + } + } + } + + Spacer().frame(height: 48) + + Text(""" + To answer this question, + we need to know Korean time expressions. + + Korean time expression is a little unusual. + There are Sino Korean and native Korean expressions. + We use both expressions to indicate time. + + Usually, native korean is used for hour, + sino korean is used for minute and second. + """) + .aggro(.light, size: 16) + } + .frame(width: 480) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetSecondPage.swift b/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetSecondPage.swift new file mode 100644 index 0000000..78dbe09 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetSecondPage.swift @@ -0,0 +1,101 @@ +// +// AboutSheetSecondPage.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct AboutSheetSecondPage: View { + + @Binding var date: Date + + let speechService: SpeechService + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("For example, Let's Read times") + .aggro(.light, size: 20) + + Spacer().frame(height: 12) + + HStack(spacing: 0) { + Text("\(date.toAmPm()) ") + .foregroundStyle(.secondary) + Text("\(date.toHours())") + Text("h ") + .foregroundStyle(.secondary) + Text("\(date.toMinutes())") + Text("m ") + .foregroundStyle(.secondary) + Text("\(date.toSeconds())") + Text("s") + .foregroundStyle(.secondary) + } + .aggro(.bold, size: 36) + + Spacer().frame(height: 32) + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("In korean, you can say") + .aggro(.light, size: 20) + + HStack(spacing: 0) { + Text("\(date.toKoreanAmPm()) ") + .foregroundStyle(.secondary) + Text("\(date.toKoreanHours())") + Text("시 ") + .foregroundStyle(.secondary) + Text("\(date.toKoreanMinutes())") + Text("분 ") + .foregroundStyle(.secondary) + Text("\(date.toKoreanSeconds())") + Text("초") + .foregroundStyle(.secondary) + } + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(date.toKoreanPronunciation())]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(date.toKoreanTime()) + } + } + + Spacer().frame(height: 32) + + Text(""" + Let's start with the dark gray part. + In Korean, AM and PM are “오전” and “오후”. + Hour, minute and second are “시”, “분”, “초”. + """) + .aggro(.light, size: 16) + + Spacer().frame(height: 48) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 16) { + KoreanPronsListView("AM", "오전", "o-jeon") + KoreanPronsListView("PM", "오후", "o-hu") + KoreanPronsListView("Hour", "시", "si") + KoreanPronsListView("Minute", "분", "bun") + KoreanPronsListView("Second", "초", "cho") + } + } + } + .frame(width: 600) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetThirdPage.swift b/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetThirdPage.swift new file mode 100644 index 0000000..540ab9d --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/About/Sheet/AboutSheetThirdPage.swift @@ -0,0 +1,100 @@ +// +// AboutSheetThirdPage.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct AboutSheetThirdPage: View { + + @Binding var date: Date + + let speechService: SpeechService + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 0) { + Text("\(date.toKoreanAmPm()) ") + .foregroundStyle(.secondary) + Text("\(date.toKoreanHours())") + Text("시 ") + .foregroundStyle(.secondary) + Text("\(date.toKoreanMinutes())") + Text("분 ") + .foregroundStyle(.secondary) + Text("\(date.toKoreanSeconds())") + Text("초") + .foregroundStyle(.secondary) + } + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(date.toKoreanPronunciation())]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(date.toKoreanTime()) + } + } + + Spacer().frame(height: 24) + + Text(""" + The white part means numbers. + “\(date.toKoreanHours())” is native korean of \(date.toHours()), + “\(date.toKoreanMinutes())” and “\(date.toKoreanSeconds())” are sino korean of \(date.toMinutes()) and \(date.toSeconds()). + """) + .aggro(.light, size: 16) + + Spacer().frame(height: 32) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 16) { + KoreanPronsListView("AM", "오전", "o-jeon") + KoreanPronsListView("PM", "오후", "o-hu") + KoreanPronsListView("Hour", "시", "si") + KoreanPronsListView("Minute", "분", "bun") + KoreanPronsListView("Second", "초", "cho") + } + .frame(width: 240) + .padding(.top, 16 + 18) + + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 92 - 16) { + Group { + Text("Native KR") + Text("Sino KR") + } + .aggro(.medium, size: 15) + } + .padding(.leading, 82 + 16 + 16) + + ForEach(1..<11) { num in + KoreanPronsListView( + "\(num)", + num.nativeKorean ?? "", + num.nativePronunciation ?? "", + num.sinoKoreanTime, + num.sinoPronunciation + ) + } + } + .frame(width: 360) + } + } + .frame(width: 600) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainDetailView.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainDetailView.swift new file mode 100644 index 0000000..dc8db75 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainDetailView.swift @@ -0,0 +1,124 @@ +// +// AlarmMainDetailView.swift +// +// +// Created by 남유성 on 2/14/24. +// + +import SwiftUI + +struct AlarmMainDetailView: View { + + @Binding var selectedAmPm: Int + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + @Binding var isPresentingSheet: Bool + @Binding var isSettingAlarm: Bool + @Binding var isDeletingAlarm: Bool + @ObservedObject var alarms: Alarms + + var body: some View { + + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Hangeul Alarm") + .aggro(.bold, size: 32) + Text("There are other notation instead of 오전 [AM] and 오후 [PM].\nLearn Korean time notations and set alarms.") + .aggro(.light, size: 17) + } + + if isSettingAlarm { + VStack(alignment: .leading, spacing: 4) { + Text("Alarm will go off in") + .aggro(.light, size: 17) + Text("\(hour()):\(minute()) \(amPm())") + .aggro(.medium, size: 20) + } + } + + HStack(spacing: 16) { + if !isDeletingAlarm { + IconButton(text: "Aa") { + isPresentingSheet = true + } + .transition(.opacity) + .fullScreenCover(isPresented: $isPresentingSheet) { + AlarmMainInfoSheet() + .clearBg() + } + + if isSettingAlarm { + IconButton(Icon.list) { + withAnimation { + isSettingAlarm.toggle() + } + } + } else { + IconButton(Icon.plus) { + withAnimation { + isSettingAlarm.toggle() + } + } + } + + } + + if isSettingAlarm { + IconButton( + text: "7am", + btnSize: CGSize(width: 64, height: 48) + ) { + + selectedAmPm = 0 + selectedHour = 7 + selectedMinute = 0 + } + + IconButton( + text: "12pm", + btnSize: CGSize(width: 80, height: 48) + ) { + selectedAmPm = 1 + selectedHour = 12 + selectedMinute = 0 + } + } else { + IconButton(Icon.setting) { + withAnimation { + isDeletingAlarm.toggle() + } + } + .disabled(alarms.data.isEmpty) + } + } + .foregroundStyle(.primary) + } + .transition(.opacity) + } +} + +extension AlarmMainDetailView { + func hour() -> String { + if selectedHour == 0 { + return "12" + } + + if selectedHour < 10 { + return "0\(selectedHour)" + } + + return "\(selectedHour > 12 ? selectedHour - 12 : selectedHour)" + } + + func minute() -> String { + if selectedMinute < 10 { + return "0\(selectedMinute)" + } + + return "\(selectedMinute)" + } + + func amPm() -> String { + selectedAmPm == 0 ? "AM" : "PM" + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainInfoSheet.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainInfoSheet.swift new file mode 100644 index 0000000..2266e28 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainInfoSheet.swift @@ -0,0 +1,60 @@ +// +// AlarmMainInfoSheet.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct AlarmMainInfoSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var date: Date = Date() + @State private var offset: CGSize = .zero + @State private var alpha: Double = 1.0 + + private let speechService = SpeechService() + private let dThreshold: Double = 200 + private let vThreshold: Double = 200 + + var body: some View { + GeometryReader { gr in + TabView { + AlarmSheetFirstPage(speechService) + AlarmSheetSecondPage(speechService: speechService) + AlarmSheetThirdPage() + } + .tabViewStyle(.page) + .offset(offset) + .gesture( + DragGesture() + .onChanged { v in + let dy = v.location.y - v.startLocation.y + let y = v.translation.height + + if dy > 0 { + offset = CGSize(width: 0, height: y) + alpha = dThreshold / (abs(y) * 2.0) + } + } + .onEnded { v in + let dy = v.location.y - v.startLocation.y + let vy = v.velocity.height + + if dy > dThreshold || vy > vThreshold { + dismiss() + } else { + withAnimation { + offset = .zero + alpha = 1.0 + } + } + } + ) + .background(Color.bg) + .opacity(alpha) + .ignoresSafeArea() + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainPickerView.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainPickerView.swift new file mode 100644 index 0000000..131384a --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainPickerView.swift @@ -0,0 +1,99 @@ +// +// AlarmMainPickerView.swift +// +// +// Created by 남유성 on 2/14/24. +// + +import SwiftUI + +struct AlarmMainPickerView: View { + + @Binding var selectedAmPm: Int + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + + @Binding var isSettingAlarm: Bool + @ObservedObject var alarms: Alarms + + let gr: GeometryProxy + + var body: some View { + VStack { + if gr.size.width <= 834 { // ipad mini, 11-inch portrait + Spacer() + VStack(spacing: 0) { + MultiAlarmPickerView( + selectedAmPm: $selectedAmPm, + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + type: .small + ) + + ControlButton(icon: Icon.plus) { + withAnimation { + addAlarm() + alarms.data.sort(by: <) + isSettingAlarm.toggle() + } + } + } + } else if gr.size.width <= 1133 { // ipad mini landscape, 12.9 inch portrait + Spacer() + HStack { + MultiAlarmPickerView( + selectedAmPm: $selectedAmPm, + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + type: .small + ) + Spacer() + + ControlButton(icon: Icon.plus) { + withAnimation { + addAlarm() + alarms.data.sort(by: <) + isSettingAlarm.toggle() + } + } + } + .padding([.leading, .trailing], 100) + } else { + if gr.size.width >= 1366 { + Spacer() + } + HStack(spacing: 0) { + MultiAlarmPickerView( + selectedAmPm: $selectedAmPm, + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + type: .large + ) + + Spacer() + + ControlButton(icon: Icon.plus) { + withAnimation { + addAlarm() + alarms.data.sort(by: <) + isSettingAlarm.toggle() + } + } + } + .padding([.trailing], 100) + } + Spacer() + } + } + + func addAlarm() { + let alarm = Alarm( + timeSection: selectedAmPm, + hour: selectedHour, + minute: selectedMinute, + isOn: true) + + UserDefaults.addAlarm(alarm) + alarms.data.append(alarm) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainView.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainView.swift new file mode 100644 index 0000000..fc3b5a3 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmMainView.swift @@ -0,0 +1,87 @@ +// +// AlarmMainView.swift +// +// +// Created by 남유성 on 2/7/24. +// + +import SwiftUI + +struct AlarmMainView: View { + + @State private var selectedAmPm: Int = 0 + @State private var selectedHour = 0 + @State private var selectedMinute = 0 + + @State private var isPresentingSheet = false + @State private var isSettingAlarm = false + @State private var isDeletingAlarm = false + + @StateObject var alarms: Alarms = Alarms(data: []) + + var body: some View { + NavigationStack { + GeometryReader { gr in + VStack(spacing: 0) { + HStack { + AlarmMainDetailView( + selectedAmPm: $selectedAmPm, + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + isPresentingSheet: $isPresentingSheet, + isSettingAlarm: $isSettingAlarm, + isDeletingAlarm: $isDeletingAlarm, + alarms: alarms + ) + .padding([.top, .leading], 100) + Spacer() + } + + if isSettingAlarm { + AlarmMainPickerView( + selectedAmPm: $selectedAmPm, + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + isSettingAlarm: $isSettingAlarm, + alarms: alarms, + gr: gr + ) + .transition(.backslide) + + } else { + ScrollView(.horizontal) { + LazyHStack(spacing: 28) { + ForEach(alarms.data, id: \.self.id) { alarm in + AlarmToggleView( + alarms: alarms, + alarm: alarm, + isDeleting: $isDeletingAlarm + ) + } + + if !isDeletingAlarm { + IconButton(Icon.plus, btnSize: CGSize(width: 82, height: 236)) { + withAnimation { + isSettingAlarm.toggle() + } + } + .background(Color.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } + .padding([.leading, .trailing], 100) + } + .scrollIndicators(.hidden) + .transition(.slide) + } + } + .edgesIgnoringSafeArea(.all) + .background(Color.bg) + } + } + .onAppear { + alarms.update(to: UserDefaults.getAlarmsData()) + } + } +} + diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmToggleView.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmToggleView.swift new file mode 100644 index 0000000..13ac677 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/AlarmToggleView.swift @@ -0,0 +1,134 @@ +// +// AlarmToggleView.swift +// +// +// Created by 남유성 on 2/14/24. +// + +import SwiftUI + +struct AlarmToggleView: View { + + @ObservedObject var alarms: Alarms + @ObservedObject var alarm: Alarm + @Binding var isDeleting: Bool + @State private var isSheetPresented: Bool = false + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + VStack(alignment: .trailing, spacing: -6) { + Text(koreanAmPm()) + Text(koreanHour() + "시") + Text(koreanMinute() + "분") + } + + Spacer() + .frame(height: 6) + + Text("\(hour()):\(minute()) \(amPm())") + .aggro(.light, size: 20) + .foregroundStyle(.secondary) + + Spacer() + .frame(height: 24) + + if isDeleting || isSheetPresented { + HStack { + Spacer() + Button { + withAnimation { + removeAlarm() + alarms.data.sort(by: <) + if alarms.data.isEmpty { + isDeleting = false + } + } + } label: { + Image(systemName: Icon.cancel) + .font(.system(size: 18, weight: .bold)) + .frame(width: 31, height: 31) + .foregroundStyle(.primary) + } + .background(.red) + .clipShape(Circle()) + } + } else { + Toggle(isOn: $alarm.isOn) {} + .toggleStyle(SwitchToggleStyle(tint: Color.green)) + .onTapGesture { + isSheetPresented = false + } + .onReceive(alarm.$isOn) { isOn in + updateAlarm() + NotificationService.shared.scheduleAlarm(alarm, isOn) + } + } + } + .aggro(.bold, size: 32) + .frame(width: 132) + .padding(24) + .background(Color.bg) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(.tertiary, lineWidth: 1) + ) + .onTapGesture { + withAnimation { + isSheetPresented.toggle() + } + } + .popover(isPresented: $isSheetPresented) { + MultiAlarmPickerView( + selectedAmPm: $alarm.timeSection, + selectedHour: $alarm.hour, + selectedMinute: $alarm.minute, + type: .small + ) + .padding() + .background(Color.bg) + } + } +} + +extension AlarmToggleView { + func koreanAmPm() -> String { + alarm.timeSection == 0 ? "오전" : "오후" + } + + func koreanHour() -> String { + if alarm.hour == 0 { + return "열두" + } + + return alarm.hour.nativeKoreanTime! + } + + func koreanMinute() -> String { + alarm.minute.sinoKoreanTime! + } + + func amPm() -> String { + alarm.timeSection == 0 ? "AM" : "PM" + } + + func hour() -> String { + if alarm.hour == 0 { + return "12" + } + + return "\(alarm.hour)" + } + + func minute() -> String { + alarm.minute < 10 ? "0\(alarm.minute)" : "\(alarm.minute)" + } + + func removeAlarm() { + alarms.data.removeAll() { $0.id == alarm.id } + UserDefaults.removeAlarm(alarm.id) + } + + func updateAlarm() { + UserDefaults.updateAlarm(alarm.id, isOn: alarm.isOn) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetFirstPage.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetFirstPage.swift new file mode 100644 index 0000000..3ebfcc6 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetFirstPage.swift @@ -0,0 +1,68 @@ +// +// AlarmSheetFirstPage.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct AlarmSheetFirstPage: View { + + let speechService: SpeechService + + init(_ speechService: SpeechService) { + self.speechService = speechService + } + + var body: some View { + VStack(alignment: .leading) { + Text("What time is 12 o'clock?") + .aggro(.bold, size: 32) + + Spacer().frame(height: 32) + + VStack(alignment: .leading, spacing: -12) { + Text("In Korea, it is usually marked") + .aggro(.light, size: 20) + + HStack(spacing: 4) { + Text("낮 12시 / 밤 12시") + .aggro(.bold, size: 32) + Text("[nat(bam)-yeol-du-si]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + Spacer() + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean("낮 12시, 밤 12시") + } + } + + Text("to reduce confusion.") + .aggro(.light, size: 20) + } + + Spacer().frame(height: 48) + + Text(""" + In Korean, Daytime and Night are “낮” and “밤”. + + We use expressions that distinguish + between morning and afternoon to distinguish the exact time. + + Many expressions are also used in Korea to reduce confusion. + This time, let's learn about time expressions. + """) + .aggro(.light, size: 16) + } + .frame(width: 600) + } +} + +#Preview { + AlarmSheetFirstPage(SpeechService()) +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetSecondPage.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetSecondPage.swift new file mode 100644 index 0000000..9416e66 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetSecondPage.swift @@ -0,0 +1,290 @@ +// +// AlarmSheetSecondPage.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct AlarmSheetSecondPage: View { + + @State var selectedTimeSection: Int = 0 + @State var selectedHour: Int = 0 + @State var selectedMinute: Int = 0 + + @State var isSheetPresented: Bool = false + + let speechService: SpeechService + + var body: some View { + VStack(alignment: .leading, spacing: 60) { + HStack(alignment: .bottom) { + VStack(alignment: .leading) { + Text("For example, Let's Read times") + .aggro(.light, size: 20) + + Spacer().frame(height: 12) + + HStack(spacing: 0) { + Text("\(selectedTimeSection == 0 ? "AM" : "PM") ") + .foregroundStyle(.secondary) + Text("\(selectedHour == 0 ? 12 : selectedHour)") + Text("h ") + .foregroundStyle(.secondary) + + if selectedMinute != 0 { + Text("\(selectedMinute)") + Text("m ") + .foregroundStyle(.secondary) + } + } + .aggro(.bold, size: 36) + } + .padding(.bottom, 20) + + Spacer() + + IconButton( + Icon.upDown, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + isSheetPresented.toggle() + } + } + .sheet(isPresented: $isSheetPresented) { + ZStack { + Color.bg + .scaleEffect(1.5) + + HStack(spacing: 0) { + TimePickerView( + selectedItem: $selectedTimeSection, + items: ["AM", "PM"], + isLoop: false, + type: .small + ) + + HStack(spacing: 4) { + TimePickerView( + selectedItem: $selectedHour, + items: [12] + Array(1..<12), + isLoop: true, + type: .small + ) + Text("h") + } + + HStack(spacing: 4) { + TimePickerView( + selectedItem: $selectedMinute, + items: Array(0..<60), + isLoop: true, + type: .small + ) + Text("m") + } + } + .aggro(.bold, size: 40) + .padding() + } + } + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("In korean, you can say") + .aggro(.light, size: 20) + + HStack(spacing: 0) { + Text(koreanTimeSecton() + " ") + .foregroundStyle(.secondary) + Text(koreanHour()) + Text("시 ") + .foregroundStyle(.secondary) + + if selectedMinute != 0 { + Text(koreanMinute()) + Text("분 ") + .foregroundStyle(.secondary) + } + } + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(koreanPronunciation())]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(koreanTime()) + } + } + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("Also, you can say") + .aggro(.light, size: 20) + + HStack(spacing: 0) { + Text(koreanTimeSecton(isAmPm: false) + " ") + .foregroundStyle(.secondary) + Text(koreanHour()) + Text("시 ") + .foregroundStyle(.secondary) + + if selectedMinute != 0 { + Text(koreanMinute()) + Text("분 ") + .foregroundStyle(.secondary) + } + } + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(koreanPronunciation(isAmPm: false))]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(koreanTime(isAmPm: false)) + } + } + + if isJajeong() || isJeongO() { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("Also, you can say") + .aggro(.light, size: 20) + + + Text(isJajeong() ? "자정" : "정오") + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(isJajeong() ? "ja-jeong" : "jeong-o")]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(isJajeong() ? "자정" : "정오") + } + } + } + } + .frame(width: 600) + } +} + +extension AlarmSheetSecondPage { + func isJajeong() -> Bool { + return selectedTimeSection == 0 && selectedHour == 0 && selectedMinute == 0 + } + + func isJeongO() -> Bool { + return selectedTimeSection == 1 && selectedHour == 0 && selectedMinute == 0 + } + + func koreanTime(isAmPm: Bool = true) -> String { + let koreanTime = koreanTimeSecton(isAmPm: isAmPm) + " " + koreanHour() + "시" + + if selectedMinute != 0 { + return koreanTime + " " + koreanMinute() + "분" + } + + return koreanTime + } + + func koreanTimeSecton(isAmPm: Bool = true) -> String { + if isAmPm { + return selectedTimeSection == 0 ? "오전" : "오후" + } + + let hour = selectedHour + + if selectedTimeSection == 0 { + if (0..<3) ~= hour { return "밤" } + if (3..<6) ~= hour { return "새벽" } + if (6..<12) ~= hour { return "아침" } + } else { + if (0..<6) ~= hour { return "낮" } + if (6..<9) ~= hour { return "저녁" } + } + + return "밤" + } + + func koreanHour() -> String { + if selectedHour == 0 { + return 12.nativeKoreanTime! + } + + return selectedHour.nativeKoreanTime! + } + + func koreanMinute() -> String { + selectedMinute.sinoKoreanTime! + } + + func koreanPronunciation(isAmPm: Bool = true) -> String { + + var pron: String = "" + + pron += koreanTimeSectionPronunciation(isAmPm: isAmPm) + pron += " " + selectedHour.nativeTimePronunciation! + "-si" + + if selectedMinute != 0 { + pron += " " + selectedMinute.sinoTimePronunciation! + "-bun" + } + + return pron + } + + func koreanTimeSectionPronunciation(isAmPm: Bool = true) -> String { + if isAmPm { + return selectedTimeSection == 0 ? "ojeon" : "ohu" + } + + let hour = selectedHour + + if selectedTimeSection == 0 { + if (0..<3) ~= hour { return "bam" } + if (3..<6) ~= hour { return "sae-byeok" } + if (6..<12) ~= hour { return "a-chim" } + } else { + if (0..<6) ~= hour { return "nat" } + if (6..<9) ~= hour { return "jeo-nyeok" } + } + + return "bam" + } +} + +#Preview { + @State var date: Date = Date() + return AlarmSheetSecondPage(speechService: SpeechService()) +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetThirdPage.swift b/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetThirdPage.swift new file mode 100644 index 0000000..acd11a5 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Alarm/Sheet/AlarmSheetThirdPage.swift @@ -0,0 +1,91 @@ +// +// AlarmSheetThirdPage.swift +// +// +// Created by 남유성 on 2/13/24. +// + +import SwiftUI + +struct AlarmSheetThirdPage: View { + var body: some View { + VStack(spacing: 48) { + + Text(""" + Instead of AM and PM, + we can use expressions in front of the time to reduce confusion in time. + + You can check the time zone expressions in the table below.
In addition, we use the expression “정오” at noon and “자정” at midnight. + """) + .aggro(.light, size: 16) + + GeometryReader { gr in + VStack(spacing: 0) { + HStack(spacing: 0) { + ForEach(0..<24) { + Text("\($0)") + .aggro(.light, size: 15) + .frame(width: gr.size.width / 24) + } + } + + HStack(spacing: 0) { + Text("밤") + .frame(width: gr.size.width / 8, height: 66) + .background(Color.gray2) + Text("새벽") + .frame(width: gr.size.width / 8, height: 66) + .background(Color.gray3) + Text("아침") + .frame(width: gr.size.width / 4, height: 66) + .background(Color.gray4) + Text("낮") + .frame(width: gr.size.width / 4, height: 66) + .background(Color.gray2) + Text("저녁") + .frame(width: gr.size.width / 8, height: 66) + .background(Color.gray3) + Text("밤") + .frame(width: gr.size.width / 8, height: 66) + .background(Color.gray4) + } + + HStack(spacing: 0) { + Text("오전") + .frame(width: gr.size.width / 2, height: 66) + .background(Color.gray1) + Text("오후") + .frame(width: gr.size.width / 2, height: 66) + .background(Color.gray5) + } + } + .aggro(.light, size: 16) + } + .frame(height: 18 + 66 + 66) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 16) { + KoreanPronsListView("AM", "오전", "o-jeon") + KoreanPronsListView("Dawn", "새벽", "sae-byeok", pronsWidth: 160) + KoreanPronsListView("Morning", "아침", "a-chim") + KoreanPronsListView("Noon", "정오", "jeong-o") + } + + Spacer() + + VStack(alignment: .leading, spacing: 16) { + KoreanPronsListView("PM", "오후", "o-hu", textWidth: 88) + KoreanPronsListView("Daytime", "낮", "nat", textWidth: 88) + KoreanPronsListView("Evening", "저녁", "jeo-nyeok", textWidth: 88, pronsWidth: 160) + KoreanPronsListView("Night", "밤", "bam", textWidth: 88) + KoreanPronsListView("Midnight", "자정", "ja-jeong", textWidth: 88) + } + } + } + .frame(width: 640) + } +} + +#Preview { + AlarmSheetThirdPage() +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Main/MainTabView.swift b/myot-si-ya/myot-si-ya/View/Scene/Main/MainTabView.swift new file mode 100644 index 0000000..216155c --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Main/MainTabView.swift @@ -0,0 +1,105 @@ +// +// ContentView.swift +// +// +// Created by 남유성 on 2/4/24. +// + +import SwiftUI +import Combine + +struct MainTabView: View { + @State private var isTabBarVisible: Bool = false + @EnvironmentObject var appState: AppState + @State private var timerCancellable: AnyCancellable? + + var body: some View { + let binding = Binding( + get: { self.appState.selectedTab }, + set: { + self.appState.selectedTab = $0 + self.setTimer() + } + ) + + return ZStack { + TabView(selection: binding) { + AboutMainView() + .tabItem { + Image(systemName: Icon.clock) + .font(.system(size: 32, weight: .thin)) + Text("Clock") + } + .tag(0) + .toolbar(isTabBarVisible ? .visible : .hidden, for: .tabBar) + + AlarmMainView() + .tabItem { + Image(systemName: Icon.alarm) + .font(.system(size: 32, weight: .thin)) + Text("Alarms") + } + .tag(1) + .toolbar(isTabBarVisible ? .visible : .hidden, for: .tabBar) + + TimerMainView() + .tabItem { + Image(systemName: Icon.timer) + .font(.system(size: 32, weight: .thin)) + Text("Timers") + } + .tag(2) + .toolbar(isTabBarVisible ? .visible : .hidden, for: .tabBar) + } + .aggro(.light, size: 16) + .onAppear() { + let appearance = UITabBar.appearance() + + appearance.backgroundImage = UIImage() + appearance.shadowImage = UIImage() + appearance.clipsToBounds = true + } + + if !isTabBarVisible { + VStack { + Spacer() + Button { + withAnimation { + isTabBarVisible = true + } + setTimer() + } label: { + HStack(spacing: 12) { + Image(systemName: Icon.chevronTop) + .font(.system(size: 40, weight: .thin)) + Text("Show tab") + .aggro(.light, size: 15) + } + .padding() + } + .foregroundStyle(.secondary) + Spacer() + .frame(height: 24) + } + } + } + .edgesIgnoringSafeArea([.top, .leading, .trailing]) + } + + func setTimer() { + timerCancellable?.cancel() + + timerCancellable = Timer.publish(every: 3, on: .main, in: .common) + .autoconnect() + .sink { _ in + withAnimation { + self.isTabBarVisible = false + } + } + } + +} + +#Preview { + MainTabView() +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Main/SplashView.swift b/myot-si-ya/myot-si-ya/View/Scene/Main/SplashView.swift new file mode 100644 index 0000000..4995609 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Main/SplashView.swift @@ -0,0 +1,31 @@ +// +// SplashView.swift +// +// +// Created by 남유성 on 2/20/24. +// + +import SwiftUI + +struct SplashView: View { + var body: some View { + HStack { + Spacer() + VStack { + Spacer() + Image("Logo") + .resizable() + .frame(width: 400, height: 400) + .aspectRatio(contentMode: .fit) + Spacer() + } + Spacer() + } + .ignoresSafeArea() + .background(Color.bg) + } +} + +#Preview { + SplashView() +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetFirstPage.swift b/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetFirstPage.swift new file mode 100644 index 0000000..820fd50 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetFirstPage.swift @@ -0,0 +1,52 @@ +// +// TimerSheetFirstPage.swift +// +// +// Created by 남유성 on 2/14/24. +// + +import SwiftUI + +struct TimerSheetFirstPage: View { + var body: some View { + + VStack(alignment: .leading, spacing: 48) { + Text("How can express\nafter 1 hour?") + .aggro(.bold, size: 32) + .lineSpacing(8) + + Text(""" + How do you express after a certain period of time in Korean. + + When marking time intervals in Korea, + hours, minutes, and seconds are marked + in “시간”, “분”, and “초”, respectively. + + Also, ago an after are marked as “전” and “후”. + """) + .aggro(.light, size: 16) + + HStack(alignment: .top) { + Spacer() + VStack(alignment: .leading, spacing: 16) { + KoreanPronsListView("Hour", "시", "si") + KoreanPronsListView("Minute", "분", "bun") + KoreanPronsListView("Second", "초", "cho") + } + + Spacer() + + VStack(alignment: .leading, spacing: 16) { + KoreanPronsListView("Ago", "전", "jeon") + KoreanPronsListView("After", "후", "hu") + } + Spacer() + } + } + .frame(width: 580) + } +} + +#Preview { + TimerSheetFirstPage() +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetSecondPage.swift b/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetSecondPage.swift new file mode 100644 index 0000000..129a6d4 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetSecondPage.swift @@ -0,0 +1,257 @@ +// +// TimerSheetSecondPage.swift +// +// +// Created by 남유성 on 2/14/24. +// + +import SwiftUI + +struct TimerSheetSecondPage: View { + + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + @Binding var selectedSecond: Int + + @State var isSheetPresented: Bool = false + + let speechService: SpeechService + + var body: some View { + VStack(alignment: .leading, spacing: 60) { + HStack(alignment: .bottom) { + VStack(alignment: .leading) { + Text("For example, Let’s set timer at") + .aggro(.light, size: 20) + + Spacer().frame(height: 12) + + HStack(spacing: 0) { + Text("After ") + + if selectedHour != 0 { + Text("\(selectedHour)") + Text("h ") + .foregroundStyle(.secondary) + } + + if selectedMinute != 0 { + Text("\(selectedMinute)") + Text("m ") + .foregroundStyle(.secondary) + } + + if selectedSecond != 0 { + Text("\(selectedSecond)") + Text("s ") + .foregroundStyle(.secondary) + } + } + .aggro(.bold, size: 36) + } + .padding(.bottom, 20) + + Spacer() + + IconButton( + Icon.upDown, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + isSheetPresented.toggle() + } + } + .sheet(isPresented: $isSheetPresented) { + if selectedHour == 0 && selectedMinute == 0 && selectedSecond == 0 { + selectedHour = 11 + selectedMinute = 59 + selectedSecond = 59 + } + } content: { + ZStack { + Color.bg + .scaleEffect(1.5) + + HStack(spacing: -12) { + Text("After") + + HStack { + TimePickerView( + selectedItem: $selectedHour, + items: Array(0..<12), + isLoop: true, + type: .small + ) + Text("h") + } + + HStack(spacing: 4) { + TimePickerView( + selectedItem: $selectedMinute, + items: Array(0..<60), + isLoop: true, + type: .small + ) + Text("m") + } + + HStack(spacing: 4) { + TimePickerView( + selectedItem: $selectedSecond, + items: Array(0..<60), + isLoop: true, + type: .small + ) + Text("s") + } + } + .aggro(.bold, size: 40) + } + } + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("Timer will goes off") + .aggro(.light, size: 20) + + HStack(spacing: 0) { + if selectedHour != 0 { + Text(koreanHour()) + Text("시간 ") + .foregroundStyle(.secondary) + } + + if selectedMinute != 0 { + Text(koreanMinute()) + Text("분 ") + .foregroundStyle(.secondary) + } + + if selectedSecond != 0 { + Text(koreanSecond()) + Text("초 ") + .foregroundStyle(.secondary) + } + Text("후") + } + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(koreanPronunciation()) hu]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(koreanTime() + " 후") + } + } + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text("When timer goes off,\nThe timer set in") + .aggro(.light, size: 20) + + + + HStack(spacing: 0) { + if selectedHour != 0 { + Text(koreanHour()) + Text("시간 ") + .foregroundStyle(.secondary) + } + + if selectedMinute != 0 { + Text(koreanMinute()) + Text("분 ") + .foregroundStyle(.secondary) + } + + if selectedSecond != 0 { + Text(koreanSecond()) + Text("초 ") + .foregroundStyle(.secondary) + } + Text("전") + } + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(koreanPronunciation()) jeon]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(koreanTime() + " 전") + } + } + } + .frame(width: 680) + } +} + +extension TimerSheetSecondPage { + func koreanTime() -> String { + var koreanTime = "" + + if selectedHour != 0 { + koreanTime += koreanHour() + "시간" + } + + if selectedMinute != 0 { + koreanTime += " " + koreanMinute() + "분" + } + + if selectedSecond != 0 { + koreanTime += " " + koreanSecond() + "초" + } + + return koreanTime + } + + func koreanHour() -> String { + selectedHour.nativeKoreanTime! + } + + func koreanMinute() -> String { + selectedMinute.sinoKoreanTime! + } + + func koreanSecond() -> String { + selectedSecond.sinoKoreanTime! + } + + func koreanPronunciation(isAmPm: Bool = true) -> String { + + var pron: String = "" + + if selectedHour != 0 { + pron += selectedHour.nativeTimePronunciation! + "-sigan" + } + + if selectedMinute != 0 { + pron += " " + selectedMinute.sinoTimePronunciation! + "-bun" + } + + if selectedSecond != 0 { + pron += " " + selectedSecond.sinoTimePronunciation! + "-cho" + } + + return pron + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetThirdPage.swift b/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetThirdPage.swift new file mode 100644 index 0000000..acc1c89 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Timer/Sheet/TimerSheetThirdPage.swift @@ -0,0 +1,173 @@ +// +// TimerSheetThirdPage.swift +// +// +// Created by 남유성 on 2/14/24. +// + +import SwiftUI + +struct TimerSheetThirdPage: View { + + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + @Binding var selectedSecond: Int + + let speechService: SpeechService + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 0) { + if selectedHour != 0 { + Text(koreanHour()) + Text("시간 ") + .foregroundStyle(.secondary) + } + + if selectedMinute != 0 { + Text(koreanMinute()) + Text("분 ") + .foregroundStyle(.secondary) + } + + if selectedSecond != 0 { + Text(koreanSecond()) + Text("초 ") + .foregroundStyle(.secondary) + } + Text("후") + } + .aggro(.bold, size: 36) + .padding(.top, 9.5) + + Text("[\(koreanPronunciation()) hu]") + .aggro(.light, size: 16) + .foregroundStyle(.tertiary) + .padding(.bottom, 9.5) + } + + Spacer() + + IconButton( + Icon.sound, + contentSize: 40, + btnSize: CGSize(width: 80, height: 80) + ) { + speechService.speakInKorean(koreanTime() + " 후") + } + } + + Spacer().frame(height: 24) + + VStack(alignment: .leading, spacing: 4) { + Text("It means after \(selectedHour)h \(selectedMinute)m \(selectedSecond)s.\n") + if selectedHour != 0 { + Text("“\(selectedHour.nativeKoreanTime!)” is native korean of \(selectedHour),") + } + if selectedMinute != 0 && selectedSecond != 0 { + if selectedMinute == selectedSecond { + Text("“\(selectedMinute.sinoKoreanTime!)” is sino korean of \(selectedMinute),") + } else { + Text("“\(selectedMinute.sinoKoreanTime!)” and “\(selectedSecond.sinoKoreanTime!)” are sino korean of \(selectedMinute) and \(selectedSecond),") + } + } else if selectedMinute != 0 { + Text("“\(selectedMinute.sinoKoreanTime!)” is sino korean of \(selectedHour),") + } else if selectedSecond != 0 { + Text("“\(selectedSecond.sinoKoreanTime!)” is sino korean of \(selectedHour),") + } + Text("“후” means “After“.") + } + .aggro(.light, size: 16) + + Spacer().frame(height: 32) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 16) { + KoreanPronsListView("Hour", "시간", "si-gan") + KoreanPronsListView("Minute", "분", "bun") + KoreanPronsListView("Second", "초", "cho") + KoreanPronsListView("ago", "전", "jeon") + KoreanPronsListView("After", "후", "hu") + } + .frame(width: 240) + .padding(.top, 16 + 18) + + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 92 - 16) { + Group { + Text("Native KR") + Text("Sino KR") + } + .aggro(.medium, size: 15) + } + .padding(.leading, 82 + 16 + 16) + + ForEach(1..<11) { num in + KoreanPronsListView( + "\(num)", + num.nativeKorean ?? "", + num.nativePronunciation ?? "", + num.sinoKoreanTime, + num.sinoPronunciation + ) + } + } + .frame(width: 360) + } + } + .frame(width: 600) + } +} + +extension TimerSheetThirdPage { + func koreanTime() -> String { + var koreanTime = "" + + if selectedHour != 0 { + koreanTime += koreanHour() + "시간" + } + + if selectedMinute != 0 { + koreanTime += " " + koreanMinute() + "분" + } + + if selectedSecond != 0 { + koreanTime += " " + koreanSecond() + "초" + } + + return koreanTime + } + + func koreanHour() -> String { + selectedHour.nativeKoreanTime! + } + + func koreanMinute() -> String { + selectedMinute.sinoKoreanTime! + } + + func koreanSecond() -> String { + selectedSecond.sinoKoreanTime! + } + + func koreanPronunciation(isAmPm: Bool = true) -> String { + + var pron: String = "" + + if selectedHour != 0 { + pron += selectedHour.nativeTimePronunciation! + "-si-gan" + " " + } + + if selectedMinute != 0 { + pron += selectedMinute.sinoTimePronunciation! + "-bun" + " " + } + + if selectedSecond != 0 { + pron += selectedSecond.sinoTimePronunciation! + "-cho" + } + + return pron + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainDetailView.swift b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainDetailView.swift new file mode 100644 index 0000000..c4b0ab2 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainDetailView.swift @@ -0,0 +1,70 @@ +// +// SwiftUIView.swift +// +// +// Created by 남유성 on 2/6/24. +// + +import SwiftUI + +struct TimerMainDetailView: View { + + @Binding var selectedHour: Int + @Binding var selectedMinute: Int + @Binding var selectedSecond: Int + @Binding var isPresentingSheet: Bool + + var body: some View { + + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("Hangeul Timer") + .aggro(.bold, size: 32) + Text("Set the time you want (~ 11h 59m 59s)\nand receive notification.") + .aggro(.light, size: 17) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Timer will be activated in") + .aggro(.light, size: 17) + Text("\(selectedHour)h \(selectedMinute)m \(selectedSecond)s") + .aggro(.medium, size: 20) + } + + HStack(spacing: 16) { + IconButton(text: "Aa") { + isPresentingSheet = true + } + .fullScreenCover(isPresented: $isPresentingSheet) { + TimerMainInfoSheet() + .clearBg() + } + + IconButton( + text: "30s", + btnSize: CGSize(width: 64, height: 48) + ) { + selectedHour = 0 + selectedMinute = 0 + selectedSecond = 30 + } + + IconButton( + text: "5m" + ) { + selectedHour = 0 + selectedMinute = 5 + selectedSecond = 0 + } + + IconButton(text: "1h") { + selectedHour = 1 + selectedMinute = 0 + selectedSecond = 0 + } + } + .foregroundStyle(.primary) + } + .transition(.opacity) + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainInfoSheet.swift b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainInfoSheet.swift new file mode 100644 index 0000000..78ac8a0 --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainInfoSheet.swift @@ -0,0 +1,76 @@ +// +// TimerMainInfoSheet.swift +// +// +// Created by 남유성 on 2/5/24. +// + +import SwiftUI + +struct TimerMainInfoSheet: View { + + @Environment(\.dismiss) private var dismiss + + @State private var date: Date = Date() + + @State var selectedHour: Int = 11 + @State var selectedMinute: Int = 59 + @State var selectedSecond: Int = 59 + + @State private var offset: CGSize = .zero + @State private var alpha: Double = 1.0 + + private let speechService = SpeechService() + private let dThreshold: Double = 200 + private let vThreshold: Double = 200 + + var body: some View { + GeometryReader { gr in + TabView { + TimerSheetFirstPage() + TimerSheetSecondPage( + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + selectedSecond: $selectedSecond, + speechService: speechService + ) + TimerSheetThirdPage( + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + selectedSecond: $selectedSecond, + speechService: speechService + ) + } + .tabViewStyle(.page) + .offset(offset) + .gesture( + DragGesture() + .onChanged { v in + let dy = v.location.y - v.startLocation.y + let y = v.translation.height + + if dy > 0 { + offset = CGSize(width: 0, height: y) + alpha = dThreshold / (abs(y) * 2.0) + } + } + .onEnded { v in + let dy = v.location.y - v.startLocation.y + let vy = v.velocity.height + + if dy > dThreshold || vy > vThreshold { + dismiss() + } else { + withAnimation { + offset = .zero + alpha = 1.0 + } + } + } + ) + .background(Color.bg) + .opacity(alpha) + .ignoresSafeArea() + } + } +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainView.swift b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainView.swift new file mode 100644 index 0000000..71bbf2e --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerMainView.swift @@ -0,0 +1,127 @@ +// +// TimerMainView.swift +// +// +// Created by 남유성 on 2/4/24. +// + +import SwiftUI + +struct TimerMainView: View { + @State private var selectedHour = 0 + @State private var selectedMinute = 0 + @State private var selectedSecond = 10 + @State private var remainingTime = 0 + @State private var totalTime = 0 + + @State private var isPresentingSheet = false + + @State private var isLinkActive = false + @State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + @State private var isPortrait: Bool = false + + var body: some View { + NavigationStack { + GeometryReader { gr in + VStack(spacing: 0) { + HStack { + TimerMainDetailView( + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + selectedSecond: $selectedSecond, + isPresentingSheet: $isPresentingSheet + ) + .padding([.top, .leading], 100) + Spacer() + } + + VStack { + if gr.size.width <= 834 { // ipad mini, 11-inch portrait + Spacer() + VStack(spacing: 0) { + MultiTimerPickerView( + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + selectedSecond: $selectedSecond, + type: .small + ) + + ControlButton(icon: Icon.plus) { + isLinkActive = true + totalTime = selectedHour * 3600 + selectedMinute * 60 + selectedSecond + remainingTime = selectedHour * 3600 + selectedMinute * 60 + selectedSecond + } + .navigationDestination(isPresented: $isLinkActive) { + TimerProgressView( + remainingTime: $remainingTime, + totalTime: $totalTime, + isLinkActive: $isLinkActive + ) + } + } + } else if gr.size.width <= 1133 { // ipad mini landscape, 12.9 inch portrait + Spacer() + HStack { + MultiTimerPickerView( + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + selectedSecond: $selectedSecond, + type: .small + ) + Spacer() + + ControlButton(icon: Icon.plus) { + isLinkActive = true + totalTime = selectedHour * 3600 + selectedMinute * 60 + selectedSecond + remainingTime = selectedHour * 3600 + selectedMinute * 60 + selectedSecond + } + .navigationDestination(isPresented: $isLinkActive) { + TimerProgressView( + remainingTime: $remainingTime, + totalTime: $totalTime, + isLinkActive: $isLinkActive + ) + } + } + .padding([.leading, .trailing], 100) + } else { + if gr.size.width >= 1366 { + Spacer() + } + HStack(spacing: 0) { + MultiTimerPickerView( + selectedHour: $selectedHour, + selectedMinute: $selectedMinute, + selectedSecond: $selectedSecond, + type: .large + ) + Spacer() + ControlButton(icon: Icon.plus) { + isLinkActive = true + totalTime = selectedHour * 3600 + selectedMinute * 60 + selectedSecond + remainingTime = selectedHour * 3600 + selectedMinute * 60 + selectedSecond + } + .navigationDestination(isPresented: $isLinkActive) { + TimerProgressView( + remainingTime: $remainingTime, + totalTime: $totalTime, + isLinkActive: $isLinkActive + ) + } + } + .padding([.leading, .trailing], gr.size.width >= 1366 ? 100 : 50) + } + Spacer() + } + } + .edgesIgnoringSafeArea(.all) + .background(Color.bg) + } + } + } +} + +#Preview { + TimerMainView() +} diff --git a/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerProgressView.swift b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerProgressView.swift new file mode 100644 index 0000000..be3e79d --- /dev/null +++ b/myot-si-ya/myot-si-ya/View/Scene/Timer/TimerProgressView.swift @@ -0,0 +1,236 @@ +// +// SwiftUIView.swift +// +// +// Created by 남유성 on 2/5/24. +// + +import SwiftUI +import Combine + +struct TimerProgressView: View { + @Environment(\.dismiss) var dismiss + + @State private var timerCancellable: Cancellable? = nil + @State private var isTimerRunning = false + @State private var isPaused = false + @State private var fontSize: CGFloat = 180 + @State private var isTimeOver = false + @State private var isHiddenTimer = false + + @Binding var remainingTime: Int + @Binding var totalTime: Int + @Binding var isLinkActive: Bool + + @State private var fontSize1: CGFloat = 180 + @State private var fontSize2: CGFloat = 180 + @State private var fontSize3: CGFloat = 180 + @State private var fontSize4: CGFloat = 180 + @State private var timer = Timer() + @State private var audioSerVice: AudioService? = AudioService() + var workItem: DispatchWorkItem? + + var body: some View { + VStack { + ZStack { + VStack { + Spacer() + HStack { + Spacer() + HStack(spacing: 32) { + ControlButton(icon: Icon.cancel) { + audioSerVice = nil + dismiss.callAsFunction() + } + + if !isTimeOver { + ControlButton(icon: Icon.pause, toggleIcon: Icon.play) { + if !isPaused { + timerCancellable?.cancel() + NotificationService.shared.removeTimer() + } else { + setTimer() + } + isPaused.toggle() + } + } + } + } + .padding([.leading, .bottom, .trailing], 100) + } + + if remainingTime > 0 { + ProgressView(value: Double(max(remainingTime - 1, 0)) / Double(totalTime - 1)) + .progressViewStyle(CircularProgressViewStyle(size: 570, remainingTime: $remainingTime)) + .animation(.linear(duration: 1), value: remainingTime) + + if remainingTime <= 10 { + Text(second().sinoKoreanTime!) + .padding(.top, 28) + .animatableSystemFont(weight: .bold, size: fontSize) + } else { + HStack(spacing: 0) { + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: -12) { + Text(hour().nativeKoreanTime! + "시간") + .foregroundStyle(hour() > 0 ? .primary : .quinary) + .opacity(hour() > 0 ? 1 : 0.5) + Text(minute().sinoKoreanTime! + "분") + .foregroundStyle(hour() > 0 ? .tertiary : minute() > 0 ? . primary : .quinary) + .opacity(minute() > 0 ? 1 : 0.5) + Text(second().sinoKoreanTime! + "초") + .foregroundStyle(hour() > 0 ? .quaternary : minute() > 0 ? . tertiary : .primary) + } + .padding(.top, 28) + } + .frame(width: 384) + .aggro(.bold, size: 100) + } + } else { + HStack(spacing: 0) { + Text("띠") + .animatableSystemFont(weight: .bold, size: fontSize1) + .animation(.interactiveSpring(duration: 0.1, extraBounce: 0.3), value: fontSize1) + .opacity(fontSize1 > 0 ? 1 : 0) + Text("띠") + .animatableSystemFont(weight: .bold, size: fontSize2) + .animation(.interactiveSpring(duration: 0.1, extraBounce: 0.3), value: fontSize2) + .opacity(fontSize2 > 0 ? 1 : 0) + Text("띠") + .animatableSystemFont(weight: .bold, size: fontSize3) + .animation(.interactiveSpring(duration: 0.1, extraBounce: 0.25), value: fontSize3) + .opacity(fontSize3 > 0 ? 1 : 0) + Text("띠") + .animatableSystemFont(weight: .bold, size: fontSize4) + .animation(.interactiveSpring(duration: 0.1, extraBounce: 0.2), value: fontSize4) + .opacity(fontSize4 > 0 ? 1 : 0) + } + .padding(.top, 30) + } + } + } + .ignoresSafeArea() + .background(Color.bg) + .toolbar(.hidden, for: .navigationBar) + .onAppear { + if !isTimerRunning { + setTimer() + isTimerRunning = true + } + } + .onDisappear { + if !isLinkActive { + timerCancellable?.cancel() + } + } + } + + fileprivate func setTimer() { + NotificationService.shared.removeTimer() + isTimeOver = false + NotificationService.shared.addTimer(after: remainingTime) + timerCancellable = Timer.publish(every: 1, on: .main, in: .common) + .autoconnect() + .sink { _ in + if remainingTime > 0 { + + remainingTime -= 1 + + if remainingTime <= 10 { + scaleFont() + } + + if remainingTime == 0 { + isTimeOver = true + effectTimer() + } + + } else { + audioSerVice?.playAudio(fileName: "Timer", playCount: 1) + effectTimer() + } + } + } + + fileprivate func effectTimer() { + fontSize1 = 0 + fontSize2 = 0 + fontSize3 = 0 + fontSize4 = 0 + + let tasks: [() -> Void] = [ + { fontSize1 = 180 }, + { fontSize2 = 180 }, + { fontSize3 = 180 }, + { fontSize4 = 180 } + ] + + var idx = 0 + + Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in + + tasks[idx]() + idx += 1 + + if idx >= tasks.count { + timer.invalidate() + } + } + } + + + fileprivate func scaleFont() { + fontSize = 0 + withAnimation { + fontSize = 180 + } + } + + fileprivate func hour() -> Int { + remainingTime / 3600 + } + + fileprivate func minute() -> Int { + remainingTime / 60 - hour() * 60 + } + + fileprivate func second() -> Int { + remainingTime % 60 + } +} + + +public struct CircularProgressViewStyle: ProgressViewStyle { + + var size: CGFloat + @Binding var remainingTime: Int + + private let lineWidth: CGFloat = 10 + private let defaultProgress = 1.0 + + public func makeBody(configuration: ProgressViewStyleConfiguration) -> some View { + ZStack { + configuration.label + progressCircleView( + fractionCompleted: configuration.fractionCompleted ?? defaultProgress + ) + configuration.currentValueLabel + } + } + + private func progressCircleView(fractionCompleted: Double) -> some View { + Circle() + .stroke(remainingTime > 10 ? .primary : Color.red, lineWidth: lineWidth) + .opacity(0.2) + .overlay(progressFill(fractionCompleted: fractionCompleted)) + .frame(width: size, height: size) + } + + private func progressFill(fractionCompleted: Double) -> some View { + Circle() + .trim(from: 0, to: CGFloat(fractionCompleted)) + .stroke(remainingTime > 10 ? .primary : Color.red, lineWidth: lineWidth) + .frame(width: size, height: size) + .rotationEffect(.degrees(-90)) + } +} diff --git a/myot-si-ya/myot-si-ya/myot_si_yaApp.swift b/myot-si-ya/myot-si-ya/myot_si_yaApp.swift index c1d3171..d27dba3 100644 --- a/myot-si-ya/myot-si-ya/myot_si_yaApp.swift +++ b/myot-si-ya/myot-si-ya/myot_si_yaApp.swift @@ -9,9 +9,38 @@ import SwiftUI @main struct myot_si_yaApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject var appState = AppState.shared + @State var isLaunching: Bool = true + + init() { + FontManager.registerFonts() + } + var body: some Scene { WindowGroup { - ContentView() + if isLaunching { + SplashView() + .preferredColorScheme(.dark) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + withAnimation { + isLaunching = false + } + } + } + } else { + MainTabView() + .environmentObject(appState) + .preferredColorScheme(.dark) + } } } } + +class AppState: ObservableObject { + static let shared = AppState() + + @Published var selectedTab = 0 +}