feat(action): enhance icon UI and add memo prompt options
- Enlarge the count number displayed on the main tab action icon - Enhance the click feedback animation on the main tab for better visibility - Add an option to toggle memo prompts during action creation - Conditionally show memo prompts based on settings when stopping a timer or incrementing a count
This commit is contained in:
parent
f6e2963c88
commit
045f0b6f74
Binary file not shown.
@ -36,6 +36,7 @@ enum DebugSeed {
|
||||
let reading = Action(name: "독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
|
||||
reading.tags = [study]
|
||||
reading.isFavorite = true
|
||||
reading.promptsForNote = true
|
||||
let english = Action(name: "영어 공부", symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1)
|
||||
english.tags = [study]
|
||||
let running = Action(name: "달리기", symbolName: "figure.run", trackingType: .time, sortOrder: 2)
|
||||
@ -44,6 +45,7 @@ enum DebugSeed {
|
||||
pushup.tags = [workout]
|
||||
let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4)
|
||||
water.tags = [life]
|
||||
water.promptsForNote = true
|
||||
for action in [reading, english, running, pushup, water] {
|
||||
context.insert(action)
|
||||
}
|
||||
@ -81,6 +83,10 @@ enum DebugSeed {
|
||||
context.insert(CountEntry(action: pushup, timestamp: at(daysAgo: daysAgo, hour: 19), amount: 20))
|
||||
}
|
||||
}
|
||||
// 메모가 달린 횟수 기록 (기록 탭 메모 표시 확인용)
|
||||
let notedWater = CountEntry(action: water, timestamp: at(daysAgo: 0, hour: 20))
|
||||
notedWater.note = "자기 전 물 한 컵"
|
||||
context.insert(notedWater)
|
||||
|
||||
// 목표 + 다짐
|
||||
let toeic = Goal(
|
||||
|
||||
@ -72,6 +72,8 @@ final class Action {
|
||||
var sortOrder: Int = 0
|
||||
/// 행동 탭에서 최상단 "즐겨찾기" 섹션에 표시
|
||||
var isFavorite: Bool = false
|
||||
/// 기록 시(시간 측정 종료 / 횟수 추가) 메모 작성 창을 띄울지
|
||||
var promptsForNote: Bool = false
|
||||
var createdAt: Date = Date()
|
||||
|
||||
@Relationship(inverse: \Tag.actions)
|
||||
@ -148,6 +150,8 @@ extension TimeSession {
|
||||
final class CountEntry {
|
||||
var timestamp: Date = Date()
|
||||
var amount: Int = 1
|
||||
/// 기록에 남기는 메모 (선택)
|
||||
var note: String = ""
|
||||
|
||||
var action: Action?
|
||||
|
||||
|
||||
@ -65,6 +65,9 @@
|
||||
},
|
||||
"+%lld" : {
|
||||
|
||||
},
|
||||
"+%lld 기록 완료" : {
|
||||
|
||||
},
|
||||
"1.0" : {
|
||||
|
||||
@ -107,6 +110,9 @@
|
||||
},
|
||||
"기록 삭제 (증가 취소)" : {
|
||||
|
||||
},
|
||||
"기록 시 메모 창 표시" : {
|
||||
|
||||
},
|
||||
"기록 시각" : {
|
||||
|
||||
@ -443,6 +449,9 @@
|
||||
},
|
||||
"이름" : {
|
||||
|
||||
},
|
||||
"이번 기록에 대한 메모 (선택)" : {
|
||||
|
||||
},
|
||||
"이번 측정에 대한 메모 (선택)" : {
|
||||
|
||||
@ -524,6 +533,12 @@
|
||||
},
|
||||
"측정 기준" : {
|
||||
|
||||
},
|
||||
"켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요." : {
|
||||
|
||||
},
|
||||
"켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요." : {
|
||||
|
||||
},
|
||||
"큰 목표를 세우고, 그 안에 다짐을 추가해 보세요." : {
|
||||
|
||||
@ -616,6 +631,9 @@
|
||||
},
|
||||
"확인" : {
|
||||
|
||||
},
|
||||
"회" : {
|
||||
|
||||
},
|
||||
"횟수" : {
|
||||
|
||||
|
||||
@ -220,6 +220,14 @@ struct ActionDetailView: View {
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: Bindable(action).promptsForNote) {
|
||||
Label {
|
||||
Text("기록 시 메모 창 표시")
|
||||
} icon: {
|
||||
Image(systemName: "note.text")
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("아이콘")
|
||||
Spacer()
|
||||
@ -320,6 +328,7 @@ struct ActionEditorView: View {
|
||||
@State private var name = ""
|
||||
@State private var symbolName = "star.fill"
|
||||
@State private var trackingType: TrackingType = .time
|
||||
@State private var promptsForNote = false
|
||||
@State private var selectedTags: Set<PersistentIdentifier> = []
|
||||
@State private var showingSymbolPicker = false
|
||||
@State private var showingNewTag = false
|
||||
@ -388,6 +397,20 @@ struct ActionEditorView: View {
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section {
|
||||
Toggle(isOn: $promptsForNote) {
|
||||
Label {
|
||||
Text("기록 시 메모 창 표시")
|
||||
} icon: {
|
||||
Image(systemName: "note.text")
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(trackingType == .time
|
||||
? "켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요."
|
||||
: "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요.")
|
||||
}
|
||||
}
|
||||
.navigationTitle(action == nil ? "행동 추가" : "행동 수정")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@ -415,6 +438,7 @@ struct ActionEditorView: View {
|
||||
name = action.name
|
||||
symbolName = action.symbolName
|
||||
trackingType = action.trackingType
|
||||
promptsForNote = action.promptsForNote
|
||||
selectedTags = Set(action.tags.map(\.persistentModelID))
|
||||
}
|
||||
|
||||
@ -433,6 +457,7 @@ struct ActionEditorView: View {
|
||||
action.name = trimmed
|
||||
action.symbolName = symbolName
|
||||
action.trackingType = trackingType
|
||||
action.promptsForNote = promptsForNote
|
||||
action.tags = tags
|
||||
} else {
|
||||
let maxOrder = allActions.map(\.sortOrder).max() ?? -1
|
||||
@ -442,6 +467,7 @@ struct ActionEditorView: View {
|
||||
trackingType: trackingType,
|
||||
sortOrder: maxOrder + 1
|
||||
)
|
||||
newAction.promptsForNote = promptsForNote
|
||||
newAction.tags = tags
|
||||
context.insert(newAction)
|
||||
}
|
||||
|
||||
@ -279,6 +279,13 @@ struct HistoryView: View {
|
||||
Text(Format.time(item.entry.timestamp))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if !item.entry.note.isEmpty {
|
||||
Label(item.entry.note, systemImage: "note.text")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(2)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("+\(item.entry.amount)")
|
||||
|
||||
@ -30,6 +30,7 @@ struct MainView: View {
|
||||
@State private var recordsAction: Action?
|
||||
@State private var deletingAction: Action?
|
||||
@State private var memoSession: TimeSession?
|
||||
@State private var memoEntry: CountEntry?
|
||||
|
||||
private var anyRunning: Bool { !runningSessions.isEmpty }
|
||||
|
||||
@ -71,6 +72,9 @@ struct MainView: View {
|
||||
.sheet(item: $memoSession) { session in
|
||||
SessionMemoSheet(session: session)
|
||||
}
|
||||
.sheet(item: $memoEntry) { entry in
|
||||
CountMemoSheet(entry: entry)
|
||||
}
|
||||
.alert(
|
||||
"행동 삭제",
|
||||
isPresented: Binding(
|
||||
@ -219,18 +223,24 @@ struct MainView: View {
|
||||
LiveActivityManager.sync(context: context)
|
||||
}
|
||||
case .count:
|
||||
context.insert(CountEntry(action: action, timestamp: .now))
|
||||
let entry = CountEntry(action: action, timestamp: .now)
|
||||
context.insert(entry)
|
||||
if action.promptsForNote {
|
||||
memoEntry = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 세션 종료. "짧은 기록 무시" 설정보다 짧으면 기록하지 않고 삭제 (실수 방지)
|
||||
/// 정상 기록된 세션은 메모 작성 시트를 띄운다 (건너뛰기 가능).
|
||||
/// 정상 기록된 세션은 행동의 메모 옵션이 켜져 있으면 메모 작성 시트를 띄운다 (건너뛰기 가능).
|
||||
private func finish(_ session: TimeSession) {
|
||||
if minSessionSeconds > 0, session.duration() < Double(minSessionSeconds) {
|
||||
context.delete(session)
|
||||
} else {
|
||||
session.endAt = .now
|
||||
memoSession = session
|
||||
if session.action?.promptsForNote == true {
|
||||
memoSession = session
|
||||
}
|
||||
}
|
||||
LiveActivityManager.sync(context: context)
|
||||
}
|
||||
@ -309,6 +319,63 @@ struct SessionMemoSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 횟수 기록 메모 시트
|
||||
|
||||
struct CountMemoSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let entry: CountEntry
|
||||
|
||||
@State private var text = ""
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let action = entry.action {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: action.symbolName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(action.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("+\(entry.amount) 기록 완료")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextField("이번 기록에 대한 메모 (선택)", text: $text, axis: .vertical)
|
||||
.lineLimit(3...5)
|
||||
.padding(10)
|
||||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.focused($focused)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("메모 남기기")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("건너뛰기") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("저장") {
|
||||
entry.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(280)])
|
||||
.onAppear { text = entry.note }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 지글(흔들림) 애니메이션
|
||||
|
||||
struct JiggleEffect: ViewModifier {
|
||||
@ -414,15 +481,14 @@ struct ActionButtonCell: View {
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap()
|
||||
if action.trackingType == .count {
|
||||
bounce.toggle()
|
||||
}
|
||||
bounce.toggle()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: compact ? 3 : 6) {
|
||||
HStack {
|
||||
Image(systemName: action.symbolName)
|
||||
.font(.system(size: compact ? 17 : 26, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.symbolEffect(.bounce, value: bounce)
|
||||
Spacer()
|
||||
if action.isRunning && !compact {
|
||||
Image(systemName: "record.circle")
|
||||
@ -454,7 +520,7 @@ struct ActionButtonCell: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(PressableButtonStyle())
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: bounce)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: bounce)
|
||||
.disabled(isEditing)
|
||||
}
|
||||
|
||||
@ -475,30 +541,49 @@ struct ActionButtonCell: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
case .count:
|
||||
Text(todayLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
HStack(alignment: .firstTextBaseline, spacing: compact ? 2 : 3) {
|
||||
if !compact {
|
||||
Text("오늘")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
Text("\(todayCount)")
|
||||
.font(.system(size: compact ? 18 : 27, weight: .heavy, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.contentTransition(.numericText(value: Double(todayCount)))
|
||||
Text("회")
|
||||
.font((compact ? Font.caption2 : Font.caption).weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.lineLimit(1)
|
||||
.animation(.snappy(duration: 0.3), value: todayCount)
|
||||
}
|
||||
}
|
||||
|
||||
private var todayLabel: String {
|
||||
let agg = Aggregator()
|
||||
switch action.trackingType {
|
||||
case .time:
|
||||
let seconds = agg.seconds(for: action, in: agg.math.dayRange(containing: .now))
|
||||
return seconds > 0 ? "오늘 \(Format.durationShort(seconds))" : "오늘 0분"
|
||||
case .count:
|
||||
let count = agg.count(for: action, in: agg.math.dayRange(containing: .now))
|
||||
return "오늘 \(count)회"
|
||||
}
|
||||
let seconds = agg.seconds(for: action, in: agg.math.dayRange(containing: .now))
|
||||
return seconds > 0 ? "오늘 \(Format.durationShort(seconds))" : "오늘 0분"
|
||||
}
|
||||
|
||||
private var todayCount: Int {
|
||||
let agg = Aggregator()
|
||||
return agg.count(for: action, in: agg.math.dayRange(containing: .now))
|
||||
}
|
||||
}
|
||||
|
||||
/// 누르면 크게 줄어들었다가 놓으면 튀어오르듯 복귀 (감쇠 낮은 스프링으로 오버슈트)
|
||||
struct PressableButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.93 : 1)
|
||||
.animation(.spring(duration: 0.2), value: configuration.isPressed)
|
||||
.scaleEffect(configuration.isPressed ? 0.85 : 1)
|
||||
.brightness(configuration.isPressed ? 0.08 : 0)
|
||||
.animation(
|
||||
configuration.isPressed
|
||||
? .spring(duration: 0.12)
|
||||
: .spring(response: 0.35, dampingFraction: 0.45),
|
||||
value: configuration.isPressed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +149,12 @@ struct CountRowLabel: View {
|
||||
.foregroundStyle(.secondary)
|
||||
Text(Format.time(entry.timestamp))
|
||||
.font(.body)
|
||||
if !entry.note.isEmpty {
|
||||
Label(entry.note, systemImage: "note.text")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("+\(entry.amount)")
|
||||
@ -278,6 +284,7 @@ struct CountEntryEditorView: View {
|
||||
|
||||
@State private var timestamp: Date = .now
|
||||
@State private var amount = 1
|
||||
@State private var note = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@ -290,6 +297,10 @@ struct CountEntryEditorView: View {
|
||||
Text("\(amount)회").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("메모") {
|
||||
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
}
|
||||
Section {
|
||||
Button("기록 삭제 (증가 취소)", role: .destructive) {
|
||||
context.delete(entry)
|
||||
@ -307,6 +318,7 @@ struct CountEntryEditorView: View {
|
||||
Button("저장") {
|
||||
entry.timestamp = timestamp
|
||||
entry.amount = amount
|
||||
entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@ -314,6 +326,7 @@ struct CountEntryEditorView: View {
|
||||
.onAppear {
|
||||
timestamp = entry.timestamp
|
||||
amount = entry.amount
|
||||
note = entry.note
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -329,6 +342,7 @@ struct CountAddView: View {
|
||||
@State private var amount = 1
|
||||
@State private var useNow = true
|
||||
@State private var timestamp: Date = .now
|
||||
@State private var note = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@ -350,6 +364,10 @@ struct CountAddView: View {
|
||||
DatePicker("시각", selection: $timestamp)
|
||||
}
|
||||
}
|
||||
Section("메모") {
|
||||
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
}
|
||||
}
|
||||
.navigationTitle("횟수 추가")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@ -359,11 +377,13 @@ struct CountAddView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("추가") {
|
||||
context.insert(CountEntry(
|
||||
let entry = CountEntry(
|
||||
action: action,
|
||||
timestamp: useNow ? .now : timestamp,
|
||||
amount: amount
|
||||
))
|
||||
)
|
||||
entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
context.insert(entry)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user