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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user