Skip to content

Commit f7ccc3c

Browse files
authored
Merge pull request #18 from data-sy/feat/improve-input-ux
# feat: improve timer creation ux
2 parents f8e2029 + e61c7bc commit f7ccc3c

10 files changed

Lines changed: 147 additions & 24 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// AppConfig.swift
3+
// QuickLabelTimer
4+
//
5+
// Created by 이소연 on 8/31/25.
6+
//
7+
/// 앱의 기능적 설정 및 정책을 관리하는 중앙 저장소
8+
///
9+
/// - 사용 목적: 앱의 동작과 관련된 상수들을 한 곳에서 관리
10+
11+
import Foundation
12+
13+
enum AppConfig {
14+
static let maxLabelLength = 100
15+
// TODO: 설정값들 여기로 모으기 (예: static let maxRunningTimers = 10)
16+
}

QuickLabelTimer/QuickLabelTimer/Views/Style/AppTheme.swift renamed to QuickLabelTimer/QuickLabelTimer/Configuration/AppTheme.swift

File renamed without changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// LabelSanitizer.swift
3+
// QuickLabelTimer
4+
//
5+
// Created by 이소연 on 9/1/25.
6+
//
7+
/// 라벨 문자열을 정리(sanitize)하는 유틸리티
8+
///
9+
/// - 사용 목적: 사용자가 입력한 라벨 문자열을 최종 저장/실행 시점에 깨끗하게 정리
10+
11+
enum LabelSanitizer {
12+
13+
static func sanitizeOnSubmit(_ input: String, maxLength: Int) -> String {
14+
var result = input
15+
// 줄바꿈을 공백으로 (복붙 대비)
16+
.replacingOccurrences(of: #"\s*\n+\s*"#, with: " ", options: .regularExpression)
17+
// 연속 공백을 하나로
18+
.replacingOccurrences(of: #"\s{2,}"#, with: " ", options: .regularExpression)
19+
// 앞뒤 공백 제거
20+
.trimmingCharacters(in: .whitespacesAndNewlines)
21+
// 최대 길이 넘지 못하게
22+
if result.count > maxLength {
23+
result = String(result.prefix(maxLength))
24+
}
25+
return result
26+
}
27+
}

QuickLabelTimer/QuickLabelTimer/ViewModels/AddTimerViewModel.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import SwiftUI
1414
class AddTimerViewModel: ObservableObject {
1515
private let timerService: TimerServiceProtocol
1616

17+
let maxLabelLength = AppConfig.maxLabelLength
18+
1719
@Published var label = ""
1820
@Published var hours = 0
19-
@Published var minutes = 5
21+
@Published var minutes = 0
2022
@Published var seconds = 0
2123
@Published var selectedMode: AlarmMode
2224
@Published var activeAlert: AppAlert?
@@ -37,8 +39,10 @@ class AddTimerViewModel: ObservableObject {
3739
func startTimer() {
3840
let attributes = AlarmNotificationPolicy.getBools(from: selectedMode)
3941

42+
let sanitizedLabelForSubmit = LabelSanitizer.sanitizeOnSubmit(label, maxLength: AppConfig.maxLabelLength)
43+
4044
let success = timerService.addTimer(
41-
label: label,
45+
label: sanitizedLabelForSubmit,
4246
hours: hours,
4347
minutes: minutes,
4448
seconds: seconds,
@@ -57,8 +61,5 @@ class AddTimerViewModel: ObservableObject {
5761

5862
func resetInputFields() {
5963
label = ""
60-
hours = 0
61-
minutes = 5
62-
seconds = 0
6364
}
6465
}

QuickLabelTimer/QuickLabelTimer/ViewModels/EditPresetViewModel.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class EditPresetViewModel: ObservableObject {
1515
private let preset: TimerPreset
1616
private let presetRepository: PresetRepositoryProtocol
1717
private let timerService: TimerServiceProtocol
18+
19+
let maxLabelLength = AppConfig.maxLabelLength
1820

1921
@Published var label: String
2022
@Published var hours: Int
@@ -23,7 +25,6 @@ class EditPresetViewModel: ObservableObject {
2325
@Published var selectedMode: AlarmMode
2426
@Published var activeAlert: AppAlert?
2527
@Published var isDeleted = false
26-
// @Published var isShowingHideAlert = false
2728

2829
// 재생, 저장 유효성
2930
var totalSeconds: Int { hours * 3600 + minutes * 60 + seconds }
@@ -32,6 +33,10 @@ class EditPresetViewModel: ObservableObject {
3233
var isDurationValid: Bool { totalSeconds > 0 }
3334
var canStart: Bool { isLabelValid && isDurationValid }
3435
var canSave: Bool { isLabelValid && isDurationValid }
36+
// 실제 저장되는 라벨
37+
private var sanitizedLabelForSubmit: String {
38+
LabelSanitizer.sanitizeOnSubmit(label, maxLength: maxLabelLength)
39+
}
3540

3641
init(preset: TimerPreset, presetRepository: PresetRepositoryProtocol, timerService: TimerServiceProtocol) {
3742
self.preset = preset
@@ -56,7 +61,7 @@ class EditPresetViewModel: ObservableObject {
5661
let attributes = AlarmNotificationPolicy.getBools(from: selectedMode)
5762
presetRepository.updatePreset(
5863
preset,
59-
label: label,
64+
label: sanitizedLabelForSubmit,
6065
hours: hours,
6166
minutes: minutes,
6267
seconds: seconds,

QuickLabelTimer/QuickLabelTimer/Views/AddTimerView.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ struct AddTimerView: View {
3434
onStart: {
3535
if isLabelFocused { isLabelFocused = false }
3636
viewModel.startTimer()
37-
// // TODO: 기존 코드에서 DispatchQueue를 없앤 상태. 만약 문제가 발생한다면 다시 투입할 예정
38-
// DispatchQueue.main.async {
39-
// viewModel.startTimer()
40-
// }
4137
}
4238
)
4339
.appAlert(item: $viewModel.activeAlert)

QuickLabelTimer/QuickLabelTimer/Views/Components/Input/LabelInputField.swift

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,102 @@
1111
import SwiftUI
1212

1313
struct LabelInputField: View {
14+
let warningThreshold: Int = 80
15+
1416
@Binding var label: String
1517
@FocusState.Binding var isFocused: Bool
18+
@State private var showLimitToast = false
19+
@State private var prevCount = 0
1620

1721
var body: some View {
18-
HStack {
19-
Text("레이블")
20-
.font(.body)
21-
.foregroundColor(.primary)
22-
23-
Divider()
24-
.frame(height: 20)
25-
.overlay(Color.gray.opacity(0.4))
26-
27-
TextField("레이블을 입력하세요", text: $label)
28-
.focused($isFocused)
29-
.frame(maxWidth: .infinity)
22+
ZStack(alignment: .bottom) {
23+
HStack {
24+
Text("라벨")
25+
.font(.body)
26+
.foregroundColor(.primary)
27+
28+
Divider()
29+
.frame(height: 20)
30+
.overlay(Color.gray.opacity(0.4))
31+
32+
TextField("라벨 입력 (비워두면 자동 생성)", text: $label)
33+
.focused($isFocused)
34+
.textInputAutocapitalization(.none)
35+
.frame(maxWidth: .infinity)
36+
Spacer()
37+
38+
// 카운터: 80 이상일 때부터 표시
39+
if label.count >= warningThreshold {
40+
Text("\(label.count) / \(AppConfig.maxLabelLength)")
41+
.font(.caption)
42+
.foregroundColor(colorForCount(label.count))
43+
.transition(.opacity.combined(with: .move(edge: .trailing)))
44+
.animation(.easeInOut(duration: 0.2), value: label.count)
45+
.layoutPriority(1)
46+
}
47+
}
48+
.padding(.vertical, 16)
3049

50+
// 토스트
51+
if showLimitToast {
52+
ToastView(text: "최대 \(AppConfig.maxLabelLength)자까지 입력할 수 있어요")
53+
.transition(.move(edge: .bottom).combined(with: .opacity))
54+
.padding(.bottom, 8)
55+
}
3156
}
57+
.accessibilityElement(children: .combine)
58+
.accessibilityLabel("타이머 라벨")
59+
.accessibilityValue(label.isEmpty ? "입력되지 않음" : label)
60+
.accessibilityHint("타이머의 라벨을 입력해 주세요. 비워두면 자동으로 라벨이 생성됩니다.")
3261
.padding(.vertical, 16)
3362
.contentShape(Rectangle())
3463
.onTapGesture {
3564
isFocused = true
3665
}
66+
.onChange(of: label) { newValue in
67+
let count = newValue.count
68+
if count > AppConfig.maxLabelLength {
69+
70+
label = String(newValue.prefix(AppConfig.maxLabelLength))
71+
72+
// 초과 진입 시점에만 토스트 + 가벼운 햅틱
73+
if prevCount <= AppConfig.maxLabelLength {
74+
showLimitToastBriefly()
75+
lightHaptic()
76+
}
77+
}
78+
prevCount = label.count
79+
}
80+
}
81+
82+
private func colorForCount(_ count: Int) -> Color {
83+
if count >= AppConfig.maxLabelLength { return .red }
84+
if count >= warningThreshold { return .orange }
85+
return .secondary
86+
}
87+
88+
private func showLimitToastBriefly() {
89+
withAnimation { showLimitToast = true }
90+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
91+
withAnimation { showLimitToast = false }
92+
}
93+
}
94+
95+
private func lightHaptic() {
96+
#if os(iOS)
97+
UIImpactFeedbackGenerator(style: .light).impactOccurred()
98+
#endif
3799
}
38100
}
39101

102+
// 심플 토스트 뷰
103+
struct ToastView: View {
104+
let text: String
105+
var body: some View {
106+
Text(text)
107+
.font(.footnote)
108+
.padding(.horizontal, 12)
109+
.padding(.vertical, 8)
110+
.background(.ultraThinMaterial, in: Capsule())
111+
}
112+
}

QuickLabelTimer/QuickLabelTimer/Views/Components/Input/TimerInputForm.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ struct TimerInputForm: View {
3232
.fixedSize()
3333
TimeChipButton(label: "+5분", action: addFiveMinutes)
3434
}
35-
LabelInputField(label: $label, isFocused: $isLabelFocused)
35+
LabelInputField(
36+
label: $label,
37+
isFocused: $isLabelFocused
38+
)
3639
Divider()
3740
HStack(spacing: 24) {
3841
TimePickerGroup(

QuickLabelTimer/QuickLabelTimer/Views/MainTabView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ struct MainTabView: View {
7272
.tag(Tab.favorites)
7373
}
7474
.tabViewStyle(.page(indexDisplayMode: .automatic))
75+
.ignoresSafeArea(.keyboard, edges: .bottom)
7576
.animation(.easeInOut, value: selectedTab)
7677
.background(AppTheme.pageBackground)
7778
.onReceive(timerDidStart.receive(on: RunLoop.main)) { _ in

QuickLabelTimer/QuickLabelTimer/Views/TimerView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ struct TimerView: View {
3232
Spacer()
3333
}
3434
.padding(.horizontal)
35+
.ignoresSafeArea(.keyboard, edges: .bottom)
3536
.navigationTitle("타이머 실행")
3637
.navigationBarTitleDisplayMode(.inline)
3738
.toolbar {

0 commit comments

Comments
 (0)