From da740ade0302e52c065fcc43ab3632c8a0339f49 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 6 Jul 2026 17:24:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Implement=20basic=20features=20for=20ta?= =?UTF-8?q?g(=EA=BC=AC=EB=A6=AC=ED=91=9C)=20and=20action(=ED=96=89?= =?UTF-8?q?=EB=8F=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserInterfaceState.xcuserstate | Bin 24322 -> 24320 bytes myApp/HaruDanim/IOS/Views/ActionTabView.swift | 183 ++++++++++++++++++ myApp/HaruDanim/IOS/Views/MainTabView.swift | 4 +- myApp/HaruDanim/IOS/Views/TagTabView.swift | 126 ++++++++++++ 4 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Views/ActionTabView.swift create mode 100644 myApp/HaruDanim/IOS/Views/TagTabView.swift diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index 9cb6840475193cbc094c0ad6612bfda97a2d7ec2..daf4154e5077ff9dbeabf8d0ce850ec6a2821bc9 100644 GIT binary patch delta 2170 zcmZ{idsGzH9mi)_G9DFqsECgw3=*qw0Fhy4VPM5aI6VAaYI^wcC|A&uZW)aYX}~AoMfiBL%$#fb8Z2Y*DV9w0f>~}}G`($> zY%IZ&gBQPqigFe&_RpDJRq3nnBqpw3TP79%$`!vo3t!h5^MP*q+brXv(!8gopCR{z z*Vnq1HdYS%F}+qSH~puv0a!eikY%AQ1=x^!+-b3zC1z(m?y|7vZ1a-ooi%5ncku#W zpwjRK=2p&2vfK{;LQV_sADkYpNpn2&P!;(yZADVX$+`DeTn^zQ7bYfR6S0NZ9IO

WYeV>^;>09(sXs8WboOMwZ)r}&Qh!-xVoV!08Jj{3A_j*~@Tr!1A~}4F&&Wt2 z;)#S1zBz(9febc6ns;}Dip^Wk31V6c^<2uOnTgXBICc! z80tZ=+4Ly43v3;MWfLDq@0(5RBzED+SXT732cymDA-pwQCk?~X!ns3|!@`1$aqQCd22j<^`5fH+Td5EnvtdkFt3gr8|3z9u^HDIxr92tOAdsw@v)jXu$} zr@!}qkU)GJm3BWZ{l6p-w}_sHAP{%Vk`UfekKd1yQ2PW4;bUn7BKMSj_(KXtYDG~B zqKZhhmJ(N(OS;MFWGU$-edJs+KrSL*AuGuxWRQG^+)QpI8_B(73wfM8M}AFSBD=_I zLhiF zI!(1xXQG|{m zdJ$bgFQ%8#)%0q53%#3eraS0c^iNDY^Fojr#}qIe!!shIGCG5p@k|l3gjvPZGTWI? znLW&(nElKFrkOd)9A{dYQ_N}RTc+2VV$HL*j88J=|UH z2iq{4Z1dYHZNImDYWusb-PUc7w~w%owL9%DJF-u=7u(D2%j{M5YWu79*X{4y8}0k- zr|g$`ER(nJ1mBmB;}iHK9`FV~iJ!{P;Aioryq90Z*YF{JCI1G$imwYVnvon#7KRGL zh3AAcAxFp+@&sCN3NAqvpwJ*R2?vE{;i7O==n?J;|8j^9=$P!7>F_&h94j3=9J?Kz zj_ZzY$1O*XqgU)N#*0bfU@=7;E60eBY#2aF-bBHtB$v6w0HYe}&I)lyz=N{*2=MCq-L0>Q$WP)sv z3n;*VLSO?t5W#dX6O;fiCU z;5ax3I>1G63ETvCK=6BTPwFpmk}4HTvn8K2Pb!xJ(yygTX{l5#y(XE``_cxfR;rV> zNDb09=_9F8YLa$HyQGM8M!M@7OeJF9jcB}N2?iXmO56=Q}b0u z)z$gx8)}34H}xC!j(ShMul}TAT8tK}5!xV))Uvf4ZLAg?r}3JoNt&W*nyyXNeA;~N zWo@BWsV&i#Y1P_W+Pm5w?SR&yb5o_9@rby`{@1j0eYf7NPkvO z)ko^1^f7v-PU&`C)l2jR`VzfLe^n3a%k{VQ)%qHJtsd6j(!)C^kwBui-Po#v$X5JJk){)$S&Dhx@v_+kMO3;||_+ z|A6|TL1-u%fu2WcC_fGjehpvo>)&GPn;*-ljs@f$;2M6n3zX delta 2190 zcma))Yfuzd8iuExNz_fs&5m2;kCcD%=c`la zeZKdc@0`~-gf2aV?j9b|cbqB3lxm_(bZAUO9cqflh6cBp%KBfzE@L;bTi9*v4t75@ zEV6C5X@EH@cxzNr@P}BJ-;`u}0U6wA8iP8G3&yNQQ!?r@x{V^kGjn!@r>4wXUafh{ zi>pgwP2YDS{gD`CfN8ub9T~hGeG$z;UoyN#u`y@6DFe+lS&WNDsd35hbeODREE6rr z*K^WpYJ9Vc>Z-j}*=FMhL!E$y;;?w}TxxgIjF7%a8E-aer^Da!Q zTIico-GNL+aNx^QeWErP z)52nKB$p7bmq0>D4C zeiJ~?2d|1tC*KV}U(av(=nkwmjQ;Q}`b>U&p<7Zc`kO+5D>>m%gzp@xRTBDTjl;2ZGWD=|J@E#yiP%as5Icx>iT8=W61#~IafCQVv=S$XQ^aYalQ>J9Bf5xt7R16^ zUa@Snd}8_9(o4pXI7yHcnMK-2j&zbPQYKYWCksgrIfwL-^T-NvK3PL9Bo~oO$Tegm zxtDAuyU8B%F=eJ+@KXduQ+CQh0SZ!XY7&)0O`&S3Rn%7M1L`AcAN4VHkUC7YQpc%N zR6Es4ouzJB5$jN^#hPu+w-#87tX^x0b*^=}^-tChtj*RvS*2OkSzEGpWSyil=qYp& z?WIfTQo5Y3q&LtFbTb{I_tN|6L-b+#bJ~BDzRSchBbY>H43o@^Vtb~_m(62mu(MbX>t*M&_3Scs1^YU?Dp)x^-v2u;fg8q+;6`yt+(a&evv5{U z;3N)m8rQ@%b4R#V?jm=c>*XGB|8W3^=9uOvaFjag9V;Ar9D5y?9XA}e9X*a-9^w1* zWN`tzyjb0OTlvRN3asC1{=W^unlYnyFm!-1^dA<&6Ek&?LMkyf5q$nuR?= zNN5o{ga@v8m(?}hwZgT_b=q}Tj1f~ri)a;T(I&b?U7RGoBu*Bmiqk}|I9DtaE5#bI zR$MIBiyOs*;+KB0O>7s>h+X1&v0J<--VuM6BBUs(j}$Epl;Wi!(lBX+9xQ zS^7-+Tsk3jNw=gs($8`qIa-d9W930|f;>!4mdDB|a+;hjTVzVkl-=??`E|KT{-=CZ zzArzNf07?75lWE>>Sv1L|w)YITjeRt>7_)b(nM+M)ia#c0E|RBeKmp^;jq#%Qd@Yl1dK z%hg`irfV~_0?z5F;lAzeaeI2*58RLRetNt94#(BIHk>1*`0`X+sczF$A4x9TVK jHoaZ%)bHv4%{FCY*?qHPvd!58vj=6T1~1JVh3fwUoA?B< diff --git a/myApp/HaruDanim/IOS/Views/ActionTabView.swift b/myApp/HaruDanim/IOS/Views/ActionTabView.swift new file mode 100644 index 0000000..d8a15f8 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/ActionTabView.swift @@ -0,0 +1,183 @@ +// +// ActionTabView.swift +// HaruDanim +// +// Lists all `Action`s and hosts a sheet for creating new ones. +// + +import SwiftUI +import SwiftData + +struct ActionTabView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \Action.createdAt, order: .reverse) private var actions: [Action] + + @State private var isAddSheetPresented = false + + var body: some View { + NavigationStack { + List { + ForEach(actions) { action in + HStack(spacing: 12) { + Image(systemName: action.iconName) + .frame(width: 28) + .foregroundStyle(Color.brandPrimary) + + VStack(alignment: .leading, spacing: 4) { + Text(action.name) + if !action.tags.isEmpty { + Text(action.tags.map(\.name).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Text(action.type == .time ? "시간" : "횟수") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .onDelete(perform: deleteActions) + } + .navigationTitle("행동") + .overlay { + if actions.isEmpty { + ContentUnavailableView( + "행동이 없어요", + systemImage: "bolt", + description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.") + ) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + isAddSheetPresented = true + } label: { + Label("행동 추가", systemImage: "plus") + } + } + } + .sheet(isPresented: $isAddSheetPresented) { + AddActionSheet() + } + } + } + + private func deleteActions(at offsets: IndexSet) { + for index in offsets { + modelContext.delete(actions[index]) + } + } +} + +// MARK: - Add Action Sheet + +private struct AddActionSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @Query(sort: \Tag.createdAt, order: .reverse) private var allTags: [Tag] + + @State private var name = "" + @State private var iconName = "star.fill" + @State private var type: ActionType = .time + @State private var selectedTagIDs: Set = [] + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + NavigationStack { + Form { + Section("이름") { + TextField("행동 이름", text: $name) + } + + Section("아이콘 (SF Symbol)") { + HStack { + Image(systemName: iconName.isEmpty ? "questionmark" : iconName) + .foregroundStyle(Color.brandPrimary) + TextField("star.fill", text: $iconName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + + Section("유형") { + Picker("유형", selection: $type) { + Text("시간").tag(ActionType.time) + Text("횟수").tag(ActionType.count) + } + .pickerStyle(.segmented) + } + + Section("꼬리표") { + if allTags.isEmpty { + Text("먼저 꼬리표를 추가하세요.") + .foregroundStyle(.secondary) + } else { + ForEach(allTags) { tag in + Button { + toggle(tag) + } label: { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: tag.colorHex)) + .frame(width: 16, height: 16) + Text(tag.name) + .foregroundStyle(.primary) + Spacer() + if selectedTagIDs.contains(tag.id) { + Image(systemName: "checkmark") + .foregroundStyle(Color.brandPrimary) + } + } + } + } + } + } + } + .navigationTitle("새 행동") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("취소") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("저장") { save() } + .disabled(trimmedName.isEmpty) + } + } + } + } + + private func toggle(_ tag: Tag) { + if selectedTagIDs.contains(tag.id) { + selectedTagIDs.remove(tag.id) + } else { + selectedTagIDs.insert(tag.id) + } + } + + private func save() { + let chosenTags = allTags.filter { selectedTagIDs.contains($0.id) } + let trimmedIcon = iconName.trimmingCharacters(in: .whitespacesAndNewlines) + let action = Action( + name: trimmedName, + type: type, + iconName: trimmedIcon.isEmpty ? "circle" : trimmedIcon, + tags: chosenTags + ) + modelContext.insert(action) + dismiss() + } +} + +#Preview { + ActionTabView() + .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) +} diff --git a/myApp/HaruDanim/IOS/Views/MainTabView.swift b/myApp/HaruDanim/IOS/Views/MainTabView.swift index b9480c4..c67512d 100644 --- a/myApp/HaruDanim/IOS/Views/MainTabView.swift +++ b/myApp/HaruDanim/IOS/Views/MainTabView.swift @@ -15,12 +15,12 @@ struct MainTabView: View { Label("메인", systemImage: "house") } - Text("행동") + ActionTabView() .tabItem { Label("행동", systemImage: "bolt") } - Text("꼬리표") + TagTabView() .tabItem { Label("꼬리표", systemImage: "tag") } diff --git a/myApp/HaruDanim/IOS/Views/TagTabView.swift b/myApp/HaruDanim/IOS/Views/TagTabView.swift new file mode 100644 index 0000000..6c6458c --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/TagTabView.swift @@ -0,0 +1,126 @@ +// +// TagTabView.swift +// HaruDanim +// +// Lists all `Tag`s and hosts a sheet for creating new ones. +// + +import SwiftUI +import SwiftData + +struct TagTabView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \Tag.createdAt, order: .reverse) private var tags: [Tag] + + @State private var isAddSheetPresented = false + + var body: some View { + NavigationStack { + List { + ForEach(tags) { tag in + HStack(spacing: 12) { + Circle() + .fill(Color(hex: tag.colorHex)) + .frame(width: 20, height: 20) + Text(tag.name) + Spacer() + } + } + .onDelete(perform: deleteTags) + } + .navigationTitle("꼬리표") + .overlay { + if tags.isEmpty { + ContentUnavailableView( + "꼬리표가 없어요", + systemImage: "tag", + description: Text("오른쪽 위 + 버튼으로 꼬리표를 추가하세요.") + ) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + isAddSheetPresented = true + } label: { + Label("꼬리표 추가", systemImage: "plus") + } + } + } + .sheet(isPresented: $isAddSheetPresented) { + AddTagSheet() + } + } + } + + private func deleteTags(at offsets: IndexSet) { + for index in offsets { + modelContext.delete(tags[index]) + } + } +} + +// MARK: - Add Tag Sheet + +private struct AddTagSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var color = Color(hex: "#2F6B4F") + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + NavigationStack { + Form { + Section("이름") { + TextField("꼬리표 이름", text: $name) + } + + Section("색상") { + ColorPicker("색상 선택", selection: $color, supportsOpacity: false) + } + } + .navigationTitle("새 꼬리표") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("취소") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("저장") { save() } + .disabled(trimmedName.isEmpty) + } + } + } + } + + private func save() { + let tag = Tag(name: trimmedName, colorHex: color.toHex()) + modelContext.insert(tag) + dismiss() + } +} + +// MARK: - Color → hex helper + +extension Color { + /// Serializes the color to a `#RRGGBB` string for persistence. + func toHex() -> String { + let resolved = resolve(in: EnvironmentValues()) + return String( + format: "#%02X%02X%02X", + Int((resolved.red * 255).rounded()), + Int((resolved.green * 255).rounded()), + Int((resolved.blue * 255).rounded()) + ) + } +} + +#Preview { + TagTabView() + .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) +}