GroupTemplateEditorSheet.swift 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import SwiftUI
  2. /// Editor for per-playlist grouping template.
  3. /// Lets the user type a custom template or pick a preset.
  4. struct GroupTemplateEditorSheet: View {
  5. let playlist: Playlist
  6. @EnvironmentObject private var theme: AppTheme
  7. @Environment(\.modelContext) private var modelContext
  8. @Environment(\.dismiss) private var dismiss
  9. @State private var template: String = ""
  10. var body: some View {
  11. VStack(alignment: .leading, spacing: 16) {
  12. // Title
  13. HStack {
  14. Text("Track Grouping")
  15. .font(.title3.bold())
  16. Spacer()
  17. Button("Cancel") { dismiss() }
  18. .keyboardShortcut(.cancelAction)
  19. Button("Save") {
  20. playlist.groupTemplate = template
  21. try? modelContext.save()
  22. dismiss()
  23. }
  24. .keyboardShortcut(.defaultAction)
  25. }
  26. // Template text field
  27. VStack(alignment: .leading, spacing: 4) {
  28. Text("Group Template")
  29. .font(.caption.bold())
  30. .foregroundStyle(.secondary)
  31. TextField("e.g. {Album} ({Year})", text: $template)
  32. .font(.body.monospaced())
  33. .textFieldStyle(.roundedBorder)
  34. Text("Use placeholders like {Album}, {Artist}, {Year}, etc. Tracks with the same resolved value will be grouped together. Empty = no grouping.")
  35. .font(.caption)
  36. .foregroundStyle(.secondary)
  37. }
  38. Divider()
  39. // Presets
  40. VStack(alignment: .leading, spacing: 6) {
  41. Text("Presets")
  42. .font(.caption.bold())
  43. .foregroundStyle(.secondary)
  44. LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 4) {
  45. ForEach(GroupTemplateResolver.presets, id: \.template) { preset in
  46. Button {
  47. template = preset.template
  48. } label: {
  49. HStack(spacing: 6) {
  50. VStack(alignment: .leading, spacing: 1) {
  51. Text(preset.name)
  52. .font(.system(size: 12, weight: .medium))
  53. if !preset.template.isEmpty {
  54. Text(preset.template)
  55. .font(.system(size: 10, design: .monospaced))
  56. .foregroundStyle(.secondary)
  57. }
  58. }
  59. Spacer(minLength: 0)
  60. if template == preset.template {
  61. Image(systemName: "checkmark")
  62. .foregroundStyle(.green)
  63. .font(.system(size: 11))
  64. }
  65. }
  66. .padding(6)
  67. .background(template == preset.template ? Color.accentColor.opacity(0.1) : Color.primary.opacity(0.03))
  68. .clipShape(RoundedRectangle(cornerRadius: 6))
  69. .overlay(
  70. RoundedRectangle(cornerRadius: 6)
  71. .stroke(template == preset.template ? Color.accentColor : Color.gray.opacity(0.2), lineWidth: 1)
  72. )
  73. }
  74. .buttonStyle(.plain)
  75. }
  76. }
  77. }
  78. Divider()
  79. // Placeholders (tap to insert)
  80. VStack(alignment: .leading, spacing: 6) {
  81. Text("Placeholders (click to insert)")
  82. .font(.caption.bold())
  83. .foregroundStyle(.secondary)
  84. HStack(spacing: 6) {
  85. ForEach(GroupTemplateResolver.placeholders, id: \.token) { placeholder in
  86. Button {
  87. template += placeholder.token
  88. } label: {
  89. Text(placeholder.token)
  90. .font(.system(size: 11, design: .monospaced))
  91. .padding(.horizontal, 6)
  92. .padding(.vertical, 3)
  93. .background(Color.accentColor.opacity(0.1))
  94. .clipShape(RoundedRectangle(cornerRadius: 4))
  95. }
  96. .buttonStyle(.plain)
  97. .help(placeholder.description)
  98. }
  99. }
  100. }
  101. // Preview
  102. if !template.isEmpty {
  103. Divider()
  104. VStack(alignment: .leading, spacing: 4) {
  105. Text("Preview")
  106. .font(.caption.bold())
  107. .foregroundStyle(.secondary)
  108. Text(previewText)
  109. .font(.system(size: 13, weight: .semibold))
  110. .foregroundStyle(theme.primaryText)
  111. .padding(8)
  112. .frame(maxWidth: .infinity, alignment: .leading)
  113. .background(Color.primary.opacity(0.04))
  114. .clipShape(RoundedRectangle(cornerRadius: 6))
  115. }
  116. }
  117. Spacer()
  118. }
  119. .padding(20)
  120. .frame(width: 560, height: 440)
  121. .onAppear {
  122. template = playlist.groupTemplate
  123. }
  124. }
  125. private var previewText: String {
  126. let sample = template
  127. .replacingOccurrences(of: "{Artist}", with: "Raimundo Fagner")
  128. .replacingOccurrences(of: "{Album}", with: "Raimundo Fagner (1973)")
  129. .replacingOccurrences(of: "{Genre}", with: "MPB")
  130. .replacingOccurrences(of: "{Year}", with: "2026")
  131. .replacingOccurrences(of: "{Folder}", with: "Batch 1")
  132. .replacingOccurrences(of: "{Format}", with: "FLAC")
  133. .replacingOccurrences(of: "{BPM}", with: "90-100 BPM")
  134. .replacingOccurrences(of: "{Key}", with: "Am")
  135. return sample
  136. }
  137. }