diff --git a/Hemera/UI/Screens/Home/AreaDetailView.swift b/Hemera/UI/Screens/Home/AreaDetailView.swift index 1c59d72..a5622b6 100644 --- a/Hemera/UI/Screens/Home/AreaDetailView.swift +++ b/Hemera/UI/Screens/Home/AreaDetailView.swift @@ -101,15 +101,16 @@ private struct AreaContentView: View { } } - VStack(alignment: .leading, spacing: TileGridConstants.sectionSpacing) { - ForEach(viewModel.sections(for: area), id: \.id) { section in + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(viewModel.sections(for: area).enumerated()), id: \.element.id) { index, section in AreaDetailSection( section: section, viewModel: viewModel, overlayItem: $overlayItem, overlayTransition: overlayTransition, columns: columns, - containerWidth: proxy.size.width + containerWidth: proxy.size.width, + isFirst: index == 0 ) } } @@ -140,15 +141,16 @@ private struct UnassignedContentView: View { ) ScrollView { - VStack(alignment: .leading, spacing: TileGridConstants.sectionSpacing) { - ForEach(viewModel.sections(forUnassignedEntityIds: unassigned.entityIds), id: \.id) { section in + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(viewModel.sections(forUnassignedEntityIds: unassigned.entityIds).enumerated()), id: \.element.id) { index, section in AreaDetailSection( section: section, viewModel: viewModel, overlayItem: $overlayItem, overlayTransition: overlayTransition, columns: columns, - containerWidth: proxy.size.width + containerWidth: proxy.size.width, + isFirst: index == 0 ) } } @@ -168,21 +170,23 @@ private struct AreaDetailSection: View { let overlayTransition: Namespace.ID let columns: Int let containerWidth: CGFloat + let isFirst: Bool var body: some View { - if let title = section.title, !title.isEmpty { - Text(title) - .font(.headline) - .padding(.horizontal, TileGridConstants.padding) - } + VStack(alignment: .leading, spacing: 0) { + if let title = section.title, !title.isEmpty { + SectionHeader(title, isFirst: isFirst) + .padding(.horizontal, TileGridConstants.padding) + } - SectionGrid( - tiles: .constant(section.tiles), - columns: columns, - containerWidth: containerWidth, - isEditing: .constant(false), - content: { tile in tileView(for: tile) } - ) + SectionGrid( + tiles: .constant(section.tiles), + columns: columns, + containerWidth: containerWidth, + isEditing: .constant(false), + content: { tile in tileView(for: tile) } + ) + } } @ViewBuilder diff --git a/Hemera/UI/Screens/Home/HomeView.swift b/Hemera/UI/Screens/Home/HomeView.swift index 988fde7..c2fc15d 100644 --- a/Hemera/UI/Screens/Home/HomeView.swift +++ b/Hemera/UI/Screens/Home/HomeView.swift @@ -80,14 +80,28 @@ struct AreasView: View { private extension AreasView { - static let areaCardHeight: CGFloat = - TileGridConstants.smallTileHeight * 2 + TileGridConstants.rowSpacing + /** + Stable tile id for the synthetic "Unassigned" card, which has no backing + `AreaEntity`. Constant so it is distinguishable from real area tiles. + */ + static let unassignedTileId = UUID(stableForString: "hemera.areas.unassigned") - var gridColumns: [GridItem] { - let count = horizontalSizeClass == .compact ? 2 : 3 - return Array(repeating: GridItem(.flexible(), spacing: Mortar.Spacing.s), count: count) + /** + Tile-grid column count. Areas render as `.medium` tiles (2 grid columns + wide), so 4 columns yields 2-up in compact width and 6 columns keeps the + roomier 3-up layout in regular width. + */ + var tileColumns: Int { + horizontalSizeClass == .compact ? 4 : 6 } + /** + Renders the area grid through the shared `SectionGrid` / `TileGridEngine`, + so spacing matches the area-detail grid exactly and reordering can be + enabled later by supplying `isEditing`/`onReorder`. Each floor is one + section; a headerless section (grouping off / no floors) renders without a + `SectionHeader`. + */ @ViewBuilder func areaCardGrid(hasUnassigned: Bool, hasRealAreas: Bool) -> some View { let sections = viewModel.sections( @@ -97,60 +111,67 @@ private extension AreasView { hasUnassigned: hasUnassigned ) - ScrollView { - if sections.count == 1, sections[0].title == nil { - // Flat layout — no floors (or grouping off). Rendered exactly - // as before, with no section headers. - LazyVGrid(columns: gridColumns, spacing: Mortar.Spacing.s) { - areaCells(for: sections[0], hasRealAreas: hasRealAreas) - } - .padding(TileGridConstants.padding) - } else { - LazyVStack(spacing: Mortar.Spacing.s) { + GeometryReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { ForEach(Array(sections.enumerated()), id: \.element.id) { index, section in - Section { - LazyVGrid(columns: gridColumns, spacing: Mortar.Spacing.s) { - areaCells(for: section, hasRealAreas: hasRealAreas) + let areasById = Dictionary( + section.areas.map { (UUID(stableForString: $0.areaId), $0) }, + uniquingKeysWith: { first, _ in first } + ) + + VStack(alignment: .leading, spacing: 0) { + if let title = section.title, !title.isEmpty { + SectionHeader(title, isFirst: index == 0) + .padding(.horizontal, TileGridConstants.padding) } - } header: { - sectionHeader(section.title ?? "", isFirst: index == 0) + + SectionGrid( + tiles: .constant(tiles(for: section)), + columns: tileColumns, + containerWidth: proxy.size.width, + isEditing: .constant(false), + content: { tile in + areaCell(for: tile, areasById: areasById, hasRealAreas: hasRealAreas) + } + ) } } } - .padding(TileGridConstants.padding) + .padding(.vertical, TileGridConstants.sectionPadding) } } } + /** + Builds the tile list for a section: one `.medium` tile per area, plus a + trailing tile for the synthetic "Unassigned" card when the section owns it. + */ + func tiles(for section: AreasViewModel.AreaSection) -> [Tile] { + var tiles = section.areas.map { area in + Tile(id: UUID(stableForString: area.areaId), title: area.name, size: .medium) + } + if section.includesUnassigned { + tiles.append(Tile(id: Self.unassignedTileId, title: "", size: .medium)) + } + return tiles + } + @ViewBuilder - func areaCells(for section: AreasViewModel.AreaSection, hasRealAreas: Bool) -> some View { - ForEach(section.areas) { area in + func areaCell(for tile: Tile, areasById: [UUID: AreaEntity], hasRealAreas: Bool) -> some View { + if let area = areasById[tile.id] { NavigationLink(value: AreaDestination.area(area)) { AreaCardView(area: area) - .frame(height: Self.areaCardHeight) } .buttonStyle(.plain) - } - if section.includesUnassigned { + } else if tile.id == Self.unassignedTileId { NavigationLink(value: AreaDestination.unassigned(hasRealAreas: hasRealAreas)) { unassignedCard(hasRealAreas: hasRealAreas) - .frame(height: Self.areaCardHeight) } .buttonStyle(.plain) } } - /// Floor section title. Scrolls away with its grid (no pinning). Bold, - /// primary-colour heading with extra top breathing room for every floor - /// after the first (the first sits close under the nav title). - func sectionHeader(_ title: String, isFirst: Bool) -> some View { - Text(title) - .font(.title3.bold()) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, isFirst ? 0 : Mortar.Spacing.l) - } - func unassignedCard(hasRealAreas: Bool) -> some View { let title = AreaDestination.unassigned(hasRealAreas: hasRealAreas).displayName return EntityCard { diff --git a/Packages/Mortar/Sources/Mortar/Components/SectionHeader.swift b/Packages/Mortar/Sources/Mortar/Components/SectionHeader.swift new file mode 100644 index 0000000..3fd2ca3 --- /dev/null +++ b/Packages/Mortar/Sources/Mortar/Components/SectionHeader.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/** + Leading-aligned section title used to group content into labelled sections + (e.g. floors on the Areas tab, entity categories in an area's detail). + + The component owns all of its own vertical spacing — the breathing room + above the title (separating it from the previous section) and below it + (separating it from its own content). Call sites must not add their own + inter-section spacing. + + The top spacing separates the header from the previous section, so the + first header in a list should pass `isFirst: true` to suppress it. + */ +public struct SectionHeader: View { + + private let title: String + private let isFirst: Bool + + public init(_ title: String, isFirst: Bool = false) { + self.title = title + self.isFirst = isFirst + } + + public var body: some View { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, isFirst ? 0 : Mortar.Spacing.xxl) + .padding(.bottom, Mortar.Spacing.s) + } +}