| | |
| | | @AppStorage("tagPosition") private var tagPosition = "left" |
| | | @State private var notchHeight: CGFloat = 0 |
| | | @AppStorage("displayMode") private var displayMode = "flat" |
| | | @AppStorage("hideAppNames") private var hideAppNames = false |
| | | |
| | | private var isSideLayout: Bool { |
| | | tagPosition == "left" || tagPosition == "right" |
| | |
| | | group: group, |
| | | onSelectApp: { app in openApp(app) }, |
| | | tagFontSize: tagFontSize, |
| | | iconSize: iconSize |
| | | iconSize: iconSize, |
| | | showNames: !hideAppNames |
| | | ).id(group.id) |
| | | } |
| | | }.padding(20) |
| | |
| | | |
| | | private var containerGrid: some View { |
| | | GeometryReader { geo in |
| | | let totalWidth = geo.size.width - 40 // outer padding |
| | | let gap: CGFloat = 12 |
| | | let containerMin: CGFloat = 300 |
| | | let colCount = max(1, Int(totalWidth / containerMin)) |
| | | let colWidth = (totalWidth - gap * CGFloat(colCount - 1)) / CGFloat(colCount) |
| | | let columns = distributeColumns(groups: groups, colCount: colCount, colWidth: colWidth) |
| | | let outerPad: CGFloat = 20 |
| | | let gap: CGFloat = 16 |
| | | let available = geo.size.width - outerPad * 2 |
| | | let colW: CGFloat = 280 |
| | | let colCount = max(1, Int((available + gap) / (colW + gap))) |
| | | let actualColW = (available - gap * CGFloat(colCount - 1)) / CGFloat(colCount) |
| | | |
| | | let columns = distributeToColumns(groups: groups, colCount: colCount, colWidth: actualColW) |
| | | |
| | | ScrollView { |
| | | HStack(alignment: .top, spacing: gap) { |
| | | ForEach(0..<colCount, id: \.self) { col in |
| | | ForEach(0..<colCount, id: \.self) { ci in |
| | | LazyVStack(spacing: gap) { |
| | | ForEach(columns[col]) { group in |
| | | containerView(for: group, width: colWidth) |
| | | ForEach(columns[ci]) { group in |
| | | masonryCard(group, width: actualColW) |
| | | .id(group.id) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | .padding(20) |
| | | .padding(outerPad) |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// Distribute groups across columns, putting each group in the shortest column. |
| | | private func distributeColumns(groups: [TagGroup], colCount: Int, colWidth: CGFloat) -> [[TagGroup]] { |
| | | var columns = Array(repeating: [TagGroup](), count: colCount) |
| | | var heights = Array(repeating: CGFloat(0), count: colCount) |
| | | for group in groups { |
| | | let h = estimatedHeight(for: group, width: colWidth) |
| | | let col = heights.firstIndex(of: heights.min()!)! |
| | | columns[col].append(group) |
| | | heights[col] += h |
| | | /// Distribute groups to the shortest column. |
| | | private func distributeToColumns(groups: [TagGroup], colCount: Int, colWidth: CGFloat) -> [[TagGroup]] { |
| | | var cols = Array(repeating: [TagGroup](), count: colCount) |
| | | var h = Array(repeating: CGFloat(0), count: colCount) |
| | | for g in groups { |
| | | let est = estimatedCardHeight(g, width: colWidth) |
| | | let ci = h.firstIndex(of: h.min()!)! |
| | | cols[ci].append(g) |
| | | h[ci] += est + 16 |
| | | } |
| | | return columns |
| | | return cols |
| | | } |
| | | |
| | | /// Rough height estimate for a container. |
| | | private func estimatedHeight(for group: TagGroup, width: CGFloat) -> CGFloat { |
| | | let innerWidth = width - 32 // padding |
| | | let itemW = iconSize + 28 + 6 // item + spacing |
| | | let perRow = max(1, Int(innerWidth / itemW)) |
| | | private func estimatedCardHeight(_ group: TagGroup, width: CGFloat) -> CGFloat { |
| | | let inner = width - 32 |
| | | let itemW = iconSize + 28 + 6 |
| | | let perRow = max(1, Int(inner / itemW)) |
| | | let rows = (group.apps.count + perRow - 1) / perRow |
| | | return 30 + CGFloat(rows) * (iconSize + 30) + 32 |
| | | return 32 + CGFloat(rows) * (iconSize + 30) |
| | | } |
| | | |
| | | /// Single container: header + app grid inside rounded rect. |
| | | private func containerView(for group: TagGroup, width: CGFloat) -> some View { |
| | | private func masonryCard(_ group: TagGroup, width: CGFloat) -> some View { |
| | | VStack(alignment: .leading, spacing: 6) { |
| | | HStack(spacing: 0) { |
| | | Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1) |
| | | Text(group.name) |
| | | .font(.system(size: tagFontSize, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .padding(.horizontal, 10) |
| | | Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1) |
| | | } |
| | |
| | | spacing: 2 |
| | | ) { |
| | | ForEach(group.apps) { app in |
| | | AppGridItem(app: app, iconSize: iconSize, onSelect: { openApp(app) }) |
| | | AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) }) |
| | | } |
| | | } |
| | | } |
| | | .frame(width: width) |
| | | .frame(maxWidth: width) |
| | | .padding(16) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 14) |