From c658d5f9f4170aafea22bdb113d686a074c9cd05 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 17 Jan 2026 10:13:03 -0600 Subject: [PATCH] refactor(itinerary): embed Add button in day header row Merge Add button into DaySectionHeaderView to prevent items from being dragged between the day header and Add button. The Add button now uses a SwiftUI Button with its own tap handler instead of row selection. Changes: - Remove .addButton case from ItineraryRowItem enum - Update DaySectionHeaderView to include Add button on the right - Pass onAddTapped callback through configureDayHeaderCell - Remove AddButtonRowView (no longer needed) - Update documentation to reflect new row structure Co-Authored-By: Claude Opus 4.5 --- .../Views/ItineraryTableViewController.swift | 145 ++++++++---------- .../Views/ItineraryTableViewWrapper.swift | 4 +- 2 files changed, 61 insertions(+), 88 deletions(-) diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index eb09dfd..518a318 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -60,18 +60,18 @@ // Each day is flattened into rows in this EXACT order: // // 1. TRAVEL (if arriving this day) - "Detroit → Milwaukee" card -// 2. DAY HEADER - "Day 3 · Sunday, Mar 8" -// 3. ADD BUTTON - "+ Add" (always immediately after header) -// 4. GAMES - All games for this day (city label + cards) -// 5. CUSTOM ITEMS - User-added items sorted by sortOrder +// 2. DAY HEADER + ADD BUTTON - "Day 3 · Sunday, Mar 8 + Add" +// 3. GAMES - All games for this day (city label + cards) +// 4. CUSTOM ITEMS - User-added items sorted by sortOrder +// +// NOTE: The Add button is EMBEDDED in the day header row (not a separate row). +// This prevents items from being dragged between the header and Add button. // // Visual example: // ┌─────────────────────────────────────┐ // │ 🚗 Detroit → Milwaukee (327mi) │ ← Travel (arrives Day 3) // ├─────────────────────────────────────┤ -// │ Day 3 · Sunday, Mar 8 │ ← Day Header -// ├─────────────────────────────────────┤ -// │ + Add │ ← Add Button +// │ Day 3 · Sunday, Mar 8 + Add │ ← Day Header with Add button // ├─────────────────────────────────────┤ // │ Milwaukee │ ← Games (city + cards) // │ ┌─────────────────────────────────┐ │ @@ -91,8 +91,7 @@ // - Custom items (can move to any day, any position) // // FIXED (no drag handle): -// - Day headers (structural anchors) -// - Add buttons (always after day header) +// - Day headers with Add button (structural anchors) // - Games (determined by trip planning, not user-movable) // // ═══════════════════════════════════════════════════════════════════════════════ @@ -248,11 +247,10 @@ struct ItineraryDayData: Identifiable { /// - customItem: "item:550e8400-e29b-41d4-a716-446655440000" /// - addButton: "add:3" enum ItineraryRowItem: Identifiable, Equatable { - case dayHeader(dayNumber: Int, date: Date) // Fixed: structural anchor + case dayHeader(dayNumber: Int, date: Date) // Fixed: structural anchor (includes Add button) case games([RichGame], dayNumber: Int) // Fixed: games are trip-determined case travel(TravelSegment, dayNumber: Int) // Reorderable: within valid range case customItem(CustomItineraryItem) // Reorderable: anywhere - case addButton(day: Int) // Fixed: always after day header /// Stable identifier for table view diffing and external references. /// Travel IDs are lowercase to ensure consistency across sessions. @@ -267,16 +265,14 @@ enum ItineraryRowItem: Identifiable, Equatable { return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" case .customItem(let item): return "item:\(item.id.uuidString)" - case .addButton(let day): - return "add:\(day)" } } /// Controls whether UITableView shows the drag reorder handle. - /// Day headers, add buttons, and games are structural - users can't move them. + /// Day headers and games are structural - users can't move them. var isReorderable: Bool { switch self { - case .dayHeader, .addButton, .games: + case .dayHeader, .games: return false case .travel, .customItem: return true @@ -310,7 +306,6 @@ final class ItineraryTableViewController: UITableViewController { private let gamesCellId = "GamesCell" private let travelCellId = "TravelCell" private let customItemCellId = "CustomItemCell" - private let addButtonCellId = "AddButtonCell" // Header sizing state - prevents infinite layout loops private var lastHeaderHeight: CGFloat = 0 @@ -358,7 +353,6 @@ final class ItineraryTableViewController: UITableViewController { tableView.register(UITableViewCell.self, forCellReuseIdentifier: gamesCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: travelCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: customItemCellId) - tableView.register(UITableViewCell.self, forCellReuseIdentifier: addButtonCellId) } /// Installs a SwiftUI view as the table's header (appears above all rows). @@ -433,14 +427,13 @@ final class ItineraryTableViewController: UITableViewController { /// **Flattening Algorithm:** /// For each day, rows are added in this exact order: /// 1. Travel (if arriving this day) - appears visually BEFORE the day header - /// 2. Day header - "Day N · Date" - /// 3. Add button - always immediately after header - /// 4. Games - all games for this day (grouped as one row) - /// 5. Custom items - user-added items, already sorted by sortOrder + /// 2. Day header (with Add button) - "Day N · Date" + tappable Add button + /// 3. Games - all games for this day (grouped as one row) + /// 4. Custom items - user-added items, already sorted by sortOrder /// /// **Why this order matters:** /// - Travel before header creates visual grouping: "you travel, then you're on day N" - /// - Add button after header means it's always accessible, even on rest days + /// - Add button is part of header row (can't drag items between header and Add) /// - Games before custom items preserves the "trip-determined, then user-added" hierarchy /// /// - Parameters: @@ -458,19 +451,17 @@ final class ItineraryTableViewController: UITableViewController { flatItems.append(.travel(travel, dayNumber: day.dayNumber)) } - // 2. Day header (structural anchor - cannot be moved or deleted) + // 2. Day header with Add button (structural anchor - cannot be moved or deleted) + // Add button is embedded in the header to prevent items being dragged between them flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) - // 3. Add button (always immediately after header for easy access) - flatItems.append(.addButton(day: day.dayNumber)) - - // 4. Games for this day (bundled as one row, not individually reorderable) + // 3. Games for this day (bundled as one row, not individually reorderable) // Games are determined by the trip planning engine, not user-movable if !day.games.isEmpty { flatItems.append(.games(day.games, dayNumber: day.dayNumber)) } - // 5. Custom items (user-added, already sorted by sortOrder in day.items) + // 4. Custom items (user-added, already sorted by sortOrder in day.items) // We filter because day.items may contain other row types from wrapper for item in day.items { if case .customItem = item { @@ -572,11 +563,6 @@ final class ItineraryTableViewController: UITableViewController { let cell = tableView.dequeueReusableCell(withIdentifier: customItemCellId, for: indexPath) configureCustomItemCell(cell, item: customItem) return cell - - case .addButton(let day): - let cell = tableView.dequeueReusableCell(withIdentifier: addButtonCellId, for: indexPath) - configureAddButtonCell(cell, day: day) - return cell } } @@ -586,7 +572,7 @@ final class ItineraryTableViewController: UITableViewController { /// /// Returns `isReorderable` from the row item: /// - `true` for travel segments and custom items (user can move these) - /// - `false` for day headers, add buttons, and games (structural items) + /// - `false` for day headers and games (structural items) override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { return flatItems[indexPath.row].isReorderable } @@ -811,9 +797,9 @@ final class ItineraryTableViewController: UITableViewController { /// /// We only respond to taps on: /// - **Custom items:** Opens edit sheet via `onCustomItemTapped` - /// - **Add buttons:** Opens add sheet for that day via `onAddButtonTapped` /// - /// Day headers, games, and travel segments don't respond to taps. + /// Day headers (with embedded Add button), games, and travel don't respond to row taps. + /// The Add button within the day header handles its own tap via SwiftUI Button. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { // Immediately deselect to remove highlight tableView.deselectRow(at: indexPath, animated: true) @@ -824,11 +810,8 @@ final class ItineraryTableViewController: UITableViewController { case .customItem(let customItem): onCustomItemTapped?(customItem) - case .addButton(let day): - onAddButtonTapped?(day) - default: - break // Other row types don't respond to taps + break // Other row types don't respond to row taps } } @@ -899,7 +882,7 @@ final class ItineraryTableViewController: UITableViewController { case .dayHeader, .travel: // Hit a day boundary - no previous custom item in this day break - case .games, .addButton: + case .games: // Skip non-custom, non-boundary items continue } @@ -916,7 +899,7 @@ final class ItineraryTableViewController: UITableViewController { nextSortOrder = item.sortOrder case .dayHeader, .travel: break - case .games, .addButton: + case .games: continue } if nextSortOrder != nil { break } @@ -950,17 +933,24 @@ final class ItineraryTableViewController: UITableViewController { // 4. cell.backgroundColor = .clear for the same reason // 5. cell.selectionStyle based on whether the row is tappable - /// Day header cell - shows "Day N · Date" text. - /// Not selectable (no tap action). + /// Day header cell - shows "Day N · Date" text with embedded Add button. + /// The Add button handles its own tap via SwiftUI Button (not row selection). private func configureDayHeaderCell(_ cell: UITableViewCell, dayNumber: Int, date: Date) { cell.contentConfiguration = UIHostingConfiguration { - DaySectionHeaderView(dayNumber: dayNumber, date: date, colorScheme: colorScheme) + DaySectionHeaderView( + dayNumber: dayNumber, + date: date, + colorScheme: colorScheme, + onAddTapped: { [weak self] in + self?.onAddButtonTapped?(dayNumber) + } + ) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear - cell.selectionStyle = .none + cell.selectionStyle = .none // Row itself isn't selectable; Add button handles taps } /// Games cell - shows city label and game cards for a day. @@ -1001,19 +991,6 @@ final class ItineraryTableViewController: UITableViewController { cell.backgroundColor = .clear cell.selectionStyle = .default // Shows highlight on tap } - - /// Add button cell - tapping opens the add item sheet for this day. - /// Selectable but not draggable. - private func configureAddButtonCell(_ cell: UITableViewCell, day: Int) { - cell.contentConfiguration = UIHostingConfiguration { - AddButtonRowView(colorScheme: colorScheme) - } - .margins(.all, 0) - .background(.clear) - - cell.backgroundColor = .clear - cell.selectionStyle = .default // Shows highlight on tap - } } // MARK: - SwiftUI Row Views @@ -1026,14 +1003,21 @@ final class ItineraryTableViewController: UITableViewController { // - Pass colorScheme explicitly (UIHostingConfiguration doesn't inherit environment) // - Padding is handled by the views themselves (cell margins are set to 0) -/// Day header row - displays "Day N · Date" as section title. +/// Day header row - displays "Day N · Date" with embedded Add button. /// -/// This is a minimal header showing just the day number and formatted date. -/// Games and custom items appear in separate rows below this. +/// The Add button is part of the header row to prevent items from being +/// dragged between the header and the Add button. This ensures "Day N" and +/// "+ Add" always stay together as an atomic unit. +/// +/// Layout: +/// ``` +/// Day 3 · Sunday, Mar 8 + Add +/// ``` struct DaySectionHeaderView: View { let dayNumber: Int let date: Date let colorScheme: ColorScheme + let onAddTapped: () -> Void private var formattedDate: String { let formatter = DateFormatter() @@ -1043,6 +1027,7 @@ struct DaySectionHeaderView: View { var body: some View { HStack(alignment: .firstTextBaseline) { + // Day label and date Text("Day \(dayNumber)") .font(.title3) .fontWeight(.bold) @@ -1056,10 +1041,22 @@ struct DaySectionHeaderView: View { .foregroundStyle(Theme.textSecondary(colorScheme)) Spacer() + + // Add button (right-aligned) + Button(action: onAddTapped) { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(Theme.warmOrange.opacity(0.6)) + Text("Add") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .buttonStyle(.plain) } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.lg) // More space above for section separation - .padding(.bottom, Theme.Spacing.xs) // Less space below since add button follows + .padding(.bottom, Theme.Spacing.sm) } } @@ -1286,25 +1283,3 @@ struct CustomItemRowView: View { .padding(.vertical, Theme.Spacing.xs) } } - -/// Add button row - tapping opens the add item sheet for this day. -/// -/// Minimal design: just "+ Add" text. Appears immediately after day header, -/// making it always accessible even on rest days with no other content. -/// -/// Not draggable (structural element), but is tappable. -struct AddButtonRowView: View { - let colorScheme: ColorScheme - - var body: some View { - HStack(spacing: Theme.Spacing.xs) { - Image(systemName: "plus.circle.fill") - .foregroundStyle(Theme.warmOrange.opacity(0.6)) - Text("Add") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - .padding(.horizontal, Theme.Spacing.lg) - .padding(.vertical, Theme.Spacing.sm) - } -} diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift index 0c554da..d4d0645 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -159,10 +159,8 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent // Travel before this day (travel is stored on the destination day) let travelBefore: TravelSegment? = travelByDay[dayNum] - // ONE Add button per day - always first after day header - items.append(ItineraryRowItem.addButton(day: dayNum)) - // Custom items for this day - simply filter by day and sort by sortOrder + // Note: Add button is now embedded in the day header row (not a separate item) let dayItems = customItems.filter { $0.day == dayNum } .sorted { $0.sortOrder < $1.sortOrder }