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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 10:13:03 -06:00
parent f84addb39d
commit c658d5f9f4
2 changed files with 61 additions and 88 deletions

View File

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

View File

@@ -159,10 +159,8 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: 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 }