Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 22 additions & 18 deletions Hemera/UI/Screens/Home/AreaDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Expand Down Expand Up @@ -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
)
}
}
Expand All @@ -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
Expand Down
97 changes: 59 additions & 38 deletions Hemera/UI/Screens/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions Packages/Mortar/Sources/Mortar/Components/SectionHeader.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading