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:
songyc macbook 2026-07-08 16:40:59 +09:00
parent f6e2963c88
commit 045f0b6f74
8 changed files with 189 additions and 23 deletions

View File

@ -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(

View File

@ -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?

View File

@ -65,6 +65,9 @@
},
"+%lld" : {
},
"+%lld 기록 완료" : {
},
"1.0" : {
@ -107,6 +110,9 @@
},
"기록 삭제 (증가 취소)" : {
},
"기록 시 메모 창 표시" : {
},
"기록 시각" : {
@ -443,6 +449,9 @@
},
"이름" : {
},
"이번 기록에 대한 메모 (선택)" : {
},
"이번 측정에 대한 메모 (선택)" : {
@ -524,6 +533,12 @@
},
"측정 기준" : {
},
"켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요." : {
},
"켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요." : {
},
"큰 목표를 세우고, 그 안에 다짐을 추가해 보세요." : {
@ -616,6 +631,9 @@
},
"확인" : {
},
"회" : {
},
"횟수" : {

View File

@ -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)
}

View File

@ -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)")

View File

@ -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
)
}
}

View File

@ -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()
}
}