@@ -43,6 +43,10 @@ struct TodoEditorView: View {
4343 . sheet ( isPresented: Binding (
4444 get: { viewModel. state. showInfo } ,
4545 set: { viewModel. send ( . setShowInfo( $0) ) }
46+ ) ) {
47+ TodoEditorInfoSheetView ( viewModel: viewModel) {
48+ viewModel. send ( . setShowInfo( false ) )
49+ }
4650 }
4751 . toolbar {
4852 ToolbarLeadingButton { dismiss ( ) }
@@ -154,7 +158,24 @@ struct TodoEditorView: View {
154158 . padding ( . vertical, 8 )
155159 }
156160
157- private var editorInfoSheet: some View {
161+ private func submit( ) {
162+ let todo = viewModel. makeTodo ( )
163+ onSubmit ? ( todo)
164+ dismiss ( )
165+ }
166+
167+ private enum Field : Hashable {
168+ case title, content
169+ }
170+ }
171+
172+ private struct TodoEditorInfoSheetView : View {
173+ @Bindable var viewModel : TodoEditorViewModel
174+ let onClose : ( ) -> Void
175+ @FocusState private var isTagFieldFocused : Bool
176+ private let calendar = Calendar . current
177+
178+ var body : some View {
158179 NavigationStack {
159180 List {
160181 Section ( " 옵션 " ) {
@@ -187,28 +208,51 @@ struct TodoEditorView: View {
187208 }
188209
189210 Section ( " 태그 " ) {
190- TagEditor (
191- tags: viewModel. state. tags,
192- addAction: { viewModel. send ( . addTag( $0) ) } ,
193- deleteAction: { viewModel. send ( . removeTag( $0) ) }
194- ) {
195- Label ( " 태그 편집 " , systemImage: " tag " )
211+ HStack ( spacing: 12 ) {
212+ TextField (
213+ " 추가 " ,
214+ text: Binding (
215+ get: { viewModel. state. tagText } ,
216+ set: { viewModel. send ( . setTagText( $0) ) }
217+ )
218+ )
219+ . frame ( height: UIFont . preferredFont ( forTextStyle: . title2) . lineHeight)
220+ . textInputAutocapitalization ( . never)
221+ . focused ( $isTagFieldFocused)
222+ . onSubmit {
223+ submitTag ( )
224+ }
225+
226+ if isTagFieldFocused {
227+ Button {
228+ submitTag ( )
229+ } label: {
230+ Image ( systemName: " plus.circle.fill " )
231+ . font ( . title2)
232+ . foregroundStyle ( canSubmitTag ? . blue : . secondary)
233+ }
234+ . disabled ( !canSubmitTag)
235+ }
196236 }
197237
198238 if viewModel. state. tags. isEmpty {
199239 Text ( " 태그 없음 " )
200240 . foregroundStyle ( . secondary)
201- } else {
202- TagList ( viewModel. state. tags)
203241 . padding ( . vertical, 4 )
242+ } else {
243+ TagList (
244+ viewModel. state. tags,
245+ isEditing: isTagFieldFocused,
246+ action: { viewModel. send ( . removeTag( $0) ) }
247+ )
204248 }
205249 }
206- . alignmentGuide ( . listRowSeparatorLeading) { $0 [ . leading] }
207250 }
208251 . navigationTitle ( " 세부 정보 " )
209252 . navigationBarTitleDisplayMode ( . inline)
210253 . toolbar {
211254 ToolbarLeadingButton {
255+ onClose ( )
212256 }
213257 }
214258 }
@@ -220,7 +264,7 @@ struct TodoEditorView: View {
220264 set: { viewModel. send ( . setDueDate( $0) ) }
221265 ) ) {
222266 HStack {
223- Label ( " 마감일 " , systemImage : " calendar " )
267+ Text ( " 마감일 " )
224268 . foregroundStyle ( . primary)
225269
226270 Spacer ( )
@@ -238,14 +282,20 @@ struct TodoEditorView: View {
238282 }
239283 }
240284
241- private func submit( ) {
242- let todo = viewModel. makeTodo ( )
243- onSubmit ? ( todo)
244- dismiss ( )
285+ private func submitTag( ) {
286+ guard canSubmitTag else { return }
287+
288+ let tagText = normalizedTagText
289+ viewModel. send ( . addTag( tagText) )
290+ viewModel. send ( . setTagText( " " ) )
245291 }
246292
247- private enum Field: Hashable {
248- case title, description, tag
293+ private var normalizedTagText : String {
294+ viewModel. state. tagText. trimmingCharacters ( in: . whitespacesAndNewlines)
295+ }
296+
297+ private var canSubmitTag : Bool {
298+ !normalizedTagText. isEmpty && !viewModel. state. tags. contains ( normalizedTagText)
249299 }
250300
251301 private func dueDateText( for dueDate: Date ) -> String {
@@ -264,135 +314,6 @@ struct TodoEditorView: View {
264314 }
265315}
266316
267- private struct TagEditor< Content: View> : View {
268- @Environment ( \. safeAreaInsets) private var safeAreaInsets
269- @State private var isPresented: Bool = false
270- @State private var sheetHeight : CGFloat = . pi
271- @State private var tagsHeight : CGFloat = 0
272- @State private var fieldHeight : CGFloat = 0
273- @State private var tag = " "
274- @ViewBuilder private var content : ( ) -> Content
275- private let tags : OrderedSet < String >
276- private let addAction : ( String ) -> Void
277- private let deleteAction : ( String ) -> Void
278- private let spacing : CGFloat = 8
279-
280- init (
281- tags: OrderedSet < String > ,
282- addAction: @escaping ( String) - > Void = { _ in } ,
283- deleteAction: @escaping ( String) - > Void = { _ in } ,
284- @ViewBuilder content: @escaping ( ) - > Content
285- ) {
286- self . tags = tags
287- self . addAction = addAction
288- self . deleteAction = deleteAction
289- self . content = content
290- }
291-
292- var body : some View {
293- Button {
294- isPresented = true
295- } label: {
296- content ( )
297- }
298- . sheet (
299- isPresented: $isPresented,
300- onDismiss: { tag = " " }
301- ) {
302- VStack ( spacing: tags. isEmpty ? 0 : spacing) {
303- ScrollView {
304- TagList ( tags, isEditing: true , action: deleteAction)
305- . background {
306- GeometryReader { geometry in
307- Color . clear
308- . onAppear {
309- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.1 ) {
310- tagsHeight = geometry. size. height
311- sheetHeight += tagsHeight + ( tagsHeight == 0 ? 0 : spacing)
312- }
313- }
314- . onChange ( of: tags) { _, newTags in
315- DispatchQueue . main. async {
316- tagsHeight = geometry. size. height
317- sheetHeight = fieldHeight + tagsHeight + ( newTags. isEmpty ? 0 : spacing)
318- }
319- }
320- }
321- }
322- }
323- . scrollIndicators ( . hidden)
324- . frame ( maxHeight: tagsHeight)
325- . padding ( . top, tags. isEmpty ? 0 : 8 )
326-
327- tagField
328- . background {
329- GeometryReader { geometry in
330- Color . clear
331- . onAppear {
332- fieldHeight = geometry. size. height + 16
333- sheetHeight = fieldHeight
334- }
335- }
336- }
337-
338- }
339- . padding ( . horizontal)
340- . presentationDragIndicator ( . hidden)
341- . presentationDetents ( [ . height( sheetHeight) ] )
342- }
343- }
344-
345- private var tagField : some View {
346- HStack {
347- HStack {
348- TextField ( " 태그 입력 " , text: $tag)
349- . keyboardType ( . webSearch)
350- . padding ( tag. isEmpty ? . all : [ . leading, . vertical] )
351- . onSubmit {
352- isPresented = false
353- }
354-
355- if !tag. isEmpty {
356- Button {
357- tag = " "
358- } label: {
359- Image ( systemName: " xmark.circle.fill " )
360- . font ( . title)
361- . symbolRenderingMode ( . palette)
362- . foregroundStyle (
363- Color ( . label) ,
364- Color ( . systemBackground)
365- )
366- }
367- . padding ( . trailing)
368- }
369- }
370- . background {
371- Capsule ( )
372- . fill ( . ultraThinMaterial)
373- . overlay {
374- Capsule ( )
375- . stroke ( Color . white. opacity ( 0.2 ) , lineWidth: 1 )
376- }
377- }
378-
379- Button {
380- addAction ( tag)
381- tag = " "
382- } label: {
383- Image ( systemName: " plus " )
384- . font ( . largeTitle)
385- . foregroundStyle ( Color . white)
386- . adaptiveButtonStyle (
387- shape: . circle,
388- color: ( !tag. isEmpty && !tags. contains ( tag) ) ? Color . blue : . gray. opacity ( 0.4 )
389- )
390- }
391- . disabled ( tag. isEmpty || tags. contains ( tag) )
392- }
393- }
394- }
395-
396317private struct DueDatePicker < Content: View > : View {
397318 @Environment ( \. safeAreaInsets) private var safeAreaInsets
398319 @State private var isPresented : Bool = false
0 commit comments