Refine sidebar and detail loading behavior
This commit is contained in:
parent
5de6567924
commit
34e10a2075
@ -17,6 +17,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
var bookmarkData: Data?
|
||||
var displayName: String
|
||||
var displayItems: [MinecraftContentItem]
|
||||
var displayItemCountsByType: [MinecraftContentType: Int]
|
||||
var rawItems: [MinecraftContentItem]
|
||||
var logicalPacks: [LogicalPack]
|
||||
var logicalWorlds: [LogicalWorld]
|
||||
@ -61,6 +62,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
self.bookmarkData = bookmarkData
|
||||
self.displayName = normalizedFolderURL.lastPathComponent
|
||||
self.displayItems = []
|
||||
self.displayItemCountsByType = [:]
|
||||
self.rawItems = []
|
||||
self.logicalPacks = []
|
||||
self.logicalWorlds = []
|
||||
@ -99,6 +101,29 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
displayItems
|
||||
}
|
||||
|
||||
func items(for contentType: MinecraftContentType) -> [MinecraftContentItem] {
|
||||
displayItems.filter { $0.contentType == contentType }
|
||||
}
|
||||
|
||||
func items(matching selection: SidebarSelection?) -> [MinecraftContentItem] {
|
||||
guard let selection else {
|
||||
return []
|
||||
}
|
||||
|
||||
switch selection {
|
||||
case .source(let sourceID), .allContent(let sourceID):
|
||||
guard sourceID == id else {
|
||||
return []
|
||||
}
|
||||
return displayItems
|
||||
case .contentType(let sourceID, let contentType):
|
||||
guard sourceID == id else {
|
||||
return []
|
||||
}
|
||||
return items(for: contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func rawItem(withID itemID: URL) -> MinecraftContentItem? {
|
||||
rawItems.first(where: { $0.id == itemID })
|
||||
}
|
||||
|
||||
@ -117,12 +117,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
return
|
||||
}
|
||||
|
||||
await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown(
|
||||
from: visibleSources,
|
||||
using: persistenceStore
|
||||
)
|
||||
let sourcesToPersist = visibleSources
|
||||
shutdown()
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
|
||||
await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown(
|
||||
from: sourcesToPersist,
|
||||
using: persistenceStore,
|
||||
timeout: timeout
|
||||
)
|
||||
}
|
||||
|
||||
func addSource(at url: URL) -> URL {
|
||||
@ -303,6 +305,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
source.packInstances = index.packInstances
|
||||
source.worldPackRelationships = index.worldPackRelationships
|
||||
source.displayItems = index.displayItems
|
||||
source.displayItemCountsByType = index.displayItemCountsByType
|
||||
}
|
||||
}
|
||||
|
||||
@ -344,6 +347,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
|
||||
func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) {
|
||||
updateSource(sourceID) { source in
|
||||
source.displayItems = snapshot.displayItems
|
||||
source.displayItemCountsByType = snapshot.displayItemCountsByType
|
||||
source.rawItems = snapshot.rawItems
|
||||
source.logicalPacks = snapshot.logicalPacks
|
||||
source.logicalWorlds = snapshot.logicalWorlds
|
||||
|
||||
@ -83,10 +83,30 @@ enum SourcePersistenceCoordinator {
|
||||
|
||||
static func persistVisibleSourcesForShutdown(
|
||||
from sources: [MinecraftSource],
|
||||
using persistenceStore: SourcePersistenceStore
|
||||
using persistenceStore: SourcePersistenceStore,
|
||||
timeout: TimeInterval
|
||||
) async {
|
||||
for source in sources {
|
||||
try? await persistenceStore.save(source: source)
|
||||
guard !sources.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
for source in sources {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
try? await persistenceStore.save(source: source)
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
}
|
||||
|
||||
await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -57,6 +57,9 @@ enum SourceRestoration {
|
||||
to source: inout MinecraftSource
|
||||
) {
|
||||
source.rawItems = items
|
||||
source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||
counts[item.contentType, default: 0] += 1
|
||||
}
|
||||
source.indexedItemCount = items.count
|
||||
source.indexedDetailCount = items.filter(\.metadataLoaded).count
|
||||
source.previewLoadedCount = items.filter(\.previewLoaded).count
|
||||
|
||||
@ -14,6 +14,7 @@ struct SourceContentIndex {
|
||||
let packInstances: [PackInstance]
|
||||
let worldPackRelationships: [WorldPackRelationship]
|
||||
let displayItems: [MinecraftContentItem]
|
||||
let displayItemCountsByType: [MinecraftContentType: Int]
|
||||
}
|
||||
|
||||
enum SourceContentIndexer {
|
||||
@ -161,17 +162,20 @@ enum SourceContentIndexer {
|
||||
$0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
|
||||
}
|
||||
|
||||
let displayItems = buildDisplayItems(
|
||||
from: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
rawItemsByID: rawItemsByID
|
||||
)
|
||||
|
||||
return SourceContentIndex(
|
||||
rawItems: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
logicalWorlds: sortedLogicalWorlds,
|
||||
packInstances: sortedPackInstances,
|
||||
worldPackRelationships: worldRelationships,
|
||||
displayItems: buildDisplayItems(
|
||||
from: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
rawItemsByID: rawItemsByID
|
||||
)
|
||||
displayItems: displayItems,
|
||||
displayItemCountsByType: displayItemCounts(for: displayItems)
|
||||
)
|
||||
}
|
||||
|
||||
@ -213,6 +217,12 @@ enum SourceContentIndexer {
|
||||
return normalizedItems
|
||||
}
|
||||
|
||||
private static func displayItemCounts(for items: [MinecraftContentItem]) -> [MinecraftContentType: Int] {
|
||||
items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||
counts[item.contentType, default: 0] += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
|
||||
let candidateEmbedded = isEmbeddedWorldPack(candidate)
|
||||
let existingEmbedded = isEmbeddedWorldPack(existing)
|
||||
|
||||
@ -467,6 +467,7 @@ private actor EnrichmentWorkQueue {
|
||||
|
||||
struct SourceIndexSnapshot {
|
||||
let displayItems: [MinecraftContentItem]
|
||||
let displayItemCountsByType: [MinecraftContentType: Int]
|
||||
let rawItems: [MinecraftContentItem]
|
||||
let logicalPacks: [LogicalPack]
|
||||
let logicalWorlds: [LogicalWorld]
|
||||
@ -631,6 +632,9 @@ private actor SourceIndexActor {
|
||||
logicalPacks: logicalPacks,
|
||||
rawItemsByID: rawItemsByID
|
||||
)
|
||||
let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||
counts[item.contentType, default: 0] += 1
|
||||
}
|
||||
let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
|
||||
let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
|
||||
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
|
||||
@ -651,6 +655,7 @@ private actor SourceIndexActor {
|
||||
|
||||
return SourceIndexSnapshot(
|
||||
displayItems: dedupedDisplayItems,
|
||||
displayItemCountsByType: displayItemCountsByType,
|
||||
rawItems: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
logicalWorlds: [],
|
||||
@ -678,6 +683,7 @@ private actor SourceIndexActor {
|
||||
|
||||
return SourceIndexSnapshot(
|
||||
displayItems: dedupedDisplayItems,
|
||||
displayItemCountsByType: displayItemCountsByType,
|
||||
rawItems: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
logicalWorlds: [],
|
||||
@ -709,6 +715,7 @@ private actor SourceIndexActor {
|
||||
|
||||
return SourceIndexSnapshot(
|
||||
displayItems: dedupedDisplayItems,
|
||||
displayItemCountsByType: displayItemCountsByType,
|
||||
rawItems: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
logicalWorlds: [],
|
||||
@ -820,6 +827,7 @@ private actor SourceIndexActor {
|
||||
|
||||
return SourceIndexSnapshot(
|
||||
displayItems: dedupedDisplayItems,
|
||||
displayItemCountsByType: displayItemCountsByType,
|
||||
rawItems: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
logicalWorlds: logicalWorlds,
|
||||
|
||||
@ -156,6 +156,7 @@ enum SourceScanPolicy {
|
||||
enum SourceScanRecovery {
|
||||
static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
|
||||
source.displayItems = previousSource.displayItems
|
||||
source.displayItemCountsByType = previousSource.displayItemCountsByType
|
||||
source.rawItems = previousSource.rawItems
|
||||
source.logicalPacks = previousSource.logicalPacks
|
||||
source.logicalWorlds = previousSource.logicalWorlds
|
||||
|
||||
@ -31,8 +31,8 @@ struct ConnectedDeviceSourceFactory: Sendable {
|
||||
return source
|
||||
}
|
||||
|
||||
nonisolated func displayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String {
|
||||
"\(device.name) • \(container.appName)"
|
||||
nonisolated func displayName(for device: ConnectedDevice, container _: DeviceAppContainer) -> String {
|
||||
device.name
|
||||
}
|
||||
|
||||
nonisolated func makeSourceIdentifier(device: ConnectedDevice, container: DeviceAppContainer) -> URL {
|
||||
|
||||
@ -135,7 +135,7 @@ struct ItemDetailView: View {
|
||||
if !contents.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Recent Folder Contents")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.appTextStyle(.rowTitle)
|
||||
|
||||
ForEach(contents) { entry in
|
||||
HStack(spacing: 10) {
|
||||
@ -149,8 +149,7 @@ struct ItemDetailView: View {
|
||||
|
||||
if contents.count == directoryPreviewLimit {
|
||||
Text("Showing the first \(directoryPreviewLimit) items")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.fieldLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -433,17 +432,13 @@ struct ItemDetailView: View {
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(0.5)
|
||||
Text(title)
|
||||
.appSectionTitleStyle(.section)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.appCardSurface(.secondaryPanel)
|
||||
.appDetailSectionCard()
|
||||
}
|
||||
}
|
||||
|
||||
@ -451,7 +446,7 @@ struct ItemDetailView: View {
|
||||
private func packSection(title: String, packs: [ContentPackReference]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.appTextStyle(.rowTitle)
|
||||
|
||||
ForEach(packs) { pack in
|
||||
recordListRow(
|
||||
@ -466,14 +461,15 @@ struct ItemDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.emphasisLabel)
|
||||
.frame(width: 170, alignment: .leading)
|
||||
|
||||
Text(value)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -481,7 +477,7 @@ struct ItemDetailView: View {
|
||||
private func detailValueRow(title: String, value: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.emphasisLabel)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.fontWeight(.medium)
|
||||
@ -538,8 +534,7 @@ struct ItemDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Label(value, systemImage: "checkmark.circle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.supporting)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -586,8 +581,7 @@ struct ItemDetailView: View {
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.fieldLabel)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,14 +21,8 @@ struct SourceDetailView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(source.displayName)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
|
||||
Text(sourceSummary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(source.displayName)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
|
||||
if showsStatusSection {
|
||||
sourceStatusSection
|
||||
@ -47,15 +41,6 @@ struct SourceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceSummary: String {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return "Local filesystem source"
|
||||
case .connectedDevice(let device, let container):
|
||||
return "\(device.name) • \(container.appName)"
|
||||
}
|
||||
}
|
||||
|
||||
private var showsStatusSection: Bool {
|
||||
if source.isScanning || source.scanError != nil || source.availability != .available {
|
||||
return true
|
||||
@ -132,25 +117,24 @@ struct SourceDetailView: View {
|
||||
private var sourceStatusSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Status")
|
||||
.font(.headline)
|
||||
.appSectionTitleStyle(.section)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
if SourcePresentation.showsIndeterminateScanActivityIndicator(for: source) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.appActivityIndicatorStyle(.small)
|
||||
} else if !source.isScanning {
|
||||
sourceStatusIcon
|
||||
}
|
||||
|
||||
Text(statusTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.appTextStyle(.rowTitle)
|
||||
}
|
||||
|
||||
if let statusDetail, !statusDetail.isEmpty {
|
||||
Text(statusDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.supporting)
|
||||
}
|
||||
|
||||
if source.isScanning {
|
||||
@ -164,8 +148,7 @@ struct SourceDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.appCardSurface(.primaryPanel)
|
||||
.appDetailSectionCard()
|
||||
}
|
||||
}
|
||||
|
||||
@ -378,13 +361,12 @@ struct SourceDetailView: View {
|
||||
.frame(width: 14)
|
||||
|
||||
Text(stage.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.appTextStyle(.rowTitle)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(stage.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.fieldLabel)
|
||||
}
|
||||
|
||||
if stage.status == .completed {
|
||||
@ -392,7 +374,7 @@ struct SourceDetailView: View {
|
||||
} else if stage.showsIndeterminateProgress {
|
||||
ProgressView()
|
||||
.progressViewStyle(.linear)
|
||||
.controlSize(.small)
|
||||
.appActivityIndicatorStyle(.small)
|
||||
.tint(Color.appAccent)
|
||||
} else if let progress = stage.progress {
|
||||
ProgressView(value: progress, total: 1)
|
||||
@ -502,14 +484,13 @@ struct SourceDetailView: View {
|
||||
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.appSectionTitleStyle(.section)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
Text(row.0)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.emphasisLabel)
|
||||
.frame(width: 170, alignment: .leading)
|
||||
|
||||
Text(row.1)
|
||||
@ -518,8 +499,7 @@ struct SourceDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.appCardSurface(.primaryPanel)
|
||||
.appDetailSectionCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum ItemSortMode: String, CaseIterable, Identifiable {
|
||||
enum ItemSortMode: String, CaseIterable, Identifiable, Hashable, Sendable {
|
||||
case name
|
||||
case modifiedDate
|
||||
case size
|
||||
@ -33,11 +33,11 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
let subtitle: String
|
||||
let showsSubtitle: Bool
|
||||
let isRefreshing: Bool
|
||||
let isUpdatingProjection: Bool
|
||||
let items: [MinecraftContentItem]
|
||||
let searchPrompt: String
|
||||
let chooseFolderAction: () -> Void
|
||||
let dropAction: ([NSItemProvider]) -> Bool
|
||||
let dragProvider: (MinecraftContentItem) -> NSItemProvider
|
||||
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
||||
|
||||
var body: some View {
|
||||
@ -50,13 +50,18 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction)
|
||||
} else {
|
||||
List(items, selection: $selectedItemID) { item in
|
||||
ContentRowView(item: item, dragProvider: dragProvider)
|
||||
ContentRowView(item: item)
|
||||
.tag(item.id)
|
||||
.contextMenu {
|
||||
itemContextMenu(item)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.overlay {
|
||||
if isUpdatingProjection && items.isEmpty {
|
||||
ItemListLoadingOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
@ -67,7 +72,8 @@ struct ItemListColumnView<MenuContent: View>: View {
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
showsSubtitle: showsSubtitle,
|
||||
isRefreshing: isRefreshing
|
||||
isRefreshing: isRefreshing,
|
||||
isUpdatingProjection: isUpdatingProjection
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -100,13 +106,13 @@ private struct ItemListHeaderView: View {
|
||||
let subtitle: String
|
||||
let showsSubtitle: Bool
|
||||
let isRefreshing: Bool
|
||||
let isUpdatingProjection: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if showsSourceName {
|
||||
Text(sourceName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.appSectionTitleStyle(.overline)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
@ -115,47 +121,61 @@ private struct ItemListHeaderView: View {
|
||||
.font(.title2.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
|
||||
if isRefreshing {
|
||||
if isRefreshing || isUpdatingProjection {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.appActivityIndicatorStyle(.small)
|
||||
}
|
||||
}
|
||||
|
||||
if showsSubtitle {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if showsSubtitle || isUpdatingProjection {
|
||||
Text(displaySubtitle)
|
||||
.appTextStyle(.supporting)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider()
|
||||
.appListHeaderSurface()
|
||||
}
|
||||
|
||||
private var displaySubtitle: String {
|
||||
if isUpdatingProjection {
|
||||
return "Loading items..."
|
||||
}
|
||||
|
||||
return subtitle
|
||||
}
|
||||
}
|
||||
|
||||
private struct ItemListLoadingOverlay: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.appActivityIndicatorStyle(.small)
|
||||
|
||||
Text("Loading items...")
|
||||
.appTextStyle(.supporting)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentRowView: View {
|
||||
let item: MinecraftContentItem
|
||||
let dragProvider: (MinecraftContentItem) -> NSItemProvider
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
ItemThumbnailView(iconURL: item.iconURL)
|
||||
.onDrag {
|
||||
dragProvider(item)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.displayName)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(metadataLine)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.fieldLabel)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
@ -163,16 +183,8 @@ private struct ContentRowView: View {
|
||||
|
||||
if !item.metadataLoaded || !item.sizeLoaded {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.appActivityIndicatorStyle(.small)
|
||||
}
|
||||
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.help("Drag Out as Minecraft Package")
|
||||
.onDrag {
|
||||
dragProvider(item)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@ -138,6 +138,9 @@ enum PreviewFixtures {
|
||||
behaviorPackItem,
|
||||
resourcePackItem
|
||||
]
|
||||
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||
counts[item.contentType, default: 0] += 1
|
||||
}
|
||||
source.rawItems = source.displayItems
|
||||
source.logicalPacks = [
|
||||
LogicalPack(
|
||||
@ -220,6 +223,9 @@ enum PreviewFixtures {
|
||||
var source = MinecraftSource(folderURL: sourceTwoURL)
|
||||
source.displayName = "Downloads"
|
||||
source.displayItems = [secondLibraryPack]
|
||||
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
|
||||
counts[item.contentType, default: 0] += 1
|
||||
}
|
||||
source.rawItems = source.displayItems
|
||||
source.indexedItemCount = source.displayItems.count
|
||||
source.indexedDetailCount = source.displayItems.count
|
||||
@ -317,11 +323,11 @@ struct ItemListColumnPreviewContainer: View {
|
||||
subtitle: "5 items in Kid iPad Imports",
|
||||
showsSubtitle: false,
|
||||
isRefreshing: false,
|
||||
isUpdatingProjection: false,
|
||||
items: PreviewFixtures.primarySource.displayItems,
|
||||
searchPrompt: "Search Worlds",
|
||||
chooseFolderAction: {},
|
||||
dropAction: { _ in false },
|
||||
dragProvider: { _ in NSItemProvider() },
|
||||
itemContextMenu: { item in
|
||||
Button("Reveal \(item.displayName)") {}
|
||||
}
|
||||
|
||||
@ -20,6 +20,14 @@ struct ContentView: View {
|
||||
@State private var isShowingDeviceSourceSheet = false
|
||||
@State private var sortMode: ItemSortMode = .name
|
||||
@State private var directoryPreviewContents: [DirectoryEntry] = []
|
||||
@State private var itemListProjection = ItemCollectionProjection.placeholder(
|
||||
for: ItemCollectionProjectionRequest(
|
||||
selection: nil,
|
||||
searchText: "",
|
||||
sortMode: .name,
|
||||
source: nil
|
||||
)
|
||||
)
|
||||
|
||||
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
|
||||
private let deviceSourceFactory: ConnectedDeviceSourceFactory
|
||||
@ -37,11 +45,29 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
|
||||
let resolvedCurrentSource = currentSource
|
||||
let currentProjectionRequest = ItemCollectionProjectionRequest(
|
||||
selection: selectedSidebarSelection,
|
||||
searchText: searchText,
|
||||
sortMode: sortMode,
|
||||
source: resolvedCurrentSource
|
||||
)
|
||||
let reusesCurrentProjectedItems =
|
||||
itemListProjection.request.selection == currentProjectionRequest.selection &&
|
||||
itemListProjection.request.source?.id == currentProjectionRequest.source?.id
|
||||
let isUpdatingItemListProjection = itemListProjection.request != currentProjectionRequest
|
||||
let resolvedItemListProjection = itemListProjection.request == currentProjectionRequest
|
||||
? itemListProjection
|
||||
: ItemCollectionProjection.placeholder(for: currentProjectionRequest)
|
||||
let resolvedCurrentSelectedItem = currentSelectedItem(in: resolvedCurrentSource)
|
||||
let resolvedDisplayedItems = reusesCurrentProjectedItems ? itemListProjection.items : []
|
||||
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SourcesSidebarView(
|
||||
sources: library.sidebarSources,
|
||||
connectedDevices: library.connectedDevices,
|
||||
selection: $selectedSidebarSelection,
|
||||
selection: sidebarSelectionBinding,
|
||||
addSourceAction: pickFolder,
|
||||
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
|
||||
addConnectedDeviceAction: addConnectedDeviceSource(from:),
|
||||
@ -58,58 +84,58 @@ struct ContentView: View {
|
||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||
} content: {
|
||||
ItemListColumnView(
|
||||
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
||||
isEmpty: isEmptyLibrary,
|
||||
isDropTargeted: $isDropTargeted,
|
||||
selectedItemID: $selectedItemID,
|
||||
searchText: $searchText,
|
||||
sortMode: $sortMode,
|
||||
showsHeader: shouldShowItemListHeader,
|
||||
sourceName: currentSourceDisplayName,
|
||||
sourceName: resolvedItemListProjection.sourceName,
|
||||
showsSourceName: !isSidebarVisible,
|
||||
title: collectionHeaderTitle,
|
||||
subtitle: collectionHeaderSubtitle,
|
||||
showsSubtitle: isSearching,
|
||||
isRefreshing: currentSource?.isScanning == true,
|
||||
items: displayedItems,
|
||||
searchPrompt: searchPrompt,
|
||||
title: resolvedItemListProjection.title,
|
||||
subtitle: resolvedItemListProjection.subtitle,
|
||||
showsSubtitle: isSearching || isUpdatingItemListProjection,
|
||||
isRefreshing: resolvedCurrentSource?.isScanning == true,
|
||||
isUpdatingProjection: isUpdatingItemListProjection,
|
||||
items: resolvedDisplayedItems,
|
||||
searchPrompt: resolvedItemListProjection.searchPrompt,
|
||||
chooseFolderAction: pickFolder,
|
||||
dropAction: handleDroppedProviders(_:),
|
||||
dragProvider: dragProvider(for:),
|
||||
itemContextMenu: itemContextMenu(for:)
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
|
||||
} detail: {
|
||||
ItemDetailColumnView(
|
||||
item: currentSelectedItem,
|
||||
source: currentSource,
|
||||
showsSourceDetails: currentSelectedItem == nil && isSourceOverviewSelection,
|
||||
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
||||
backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [],
|
||||
isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
|
||||
item: resolvedCurrentSelectedItem,
|
||||
source: resolvedCurrentSource,
|
||||
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
|
||||
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||
worldsUsingPack: resolvedCurrentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
||||
backingPackInstances: resolvedCurrentSelectedItem.map(backingPackInstances(for:)) ?? [],
|
||||
isSuspiciousPack: resolvedCurrentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
|
||||
contents: directoryPreviewContents,
|
||||
directoryPreviewLimit: directoryPreviewLimit,
|
||||
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
|
||||
isEmpty: isEmptyLibrary,
|
||||
isPerformingItemAction: isPerformingItemAction,
|
||||
areFileActionsEnabled: areCurrentItemFileActionsEnabled,
|
||||
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
|
||||
exportTitle: resolvedCurrentSelectedItem.map(primaryActionTitle(for:)),
|
||||
exportAction: {
|
||||
guard let item = currentSelectedItem else {
|
||||
guard let item = resolvedCurrentSelectedItem else {
|
||||
return
|
||||
}
|
||||
|
||||
saveItem(item)
|
||||
},
|
||||
revealAction: {
|
||||
guard let item = currentSelectedItem else {
|
||||
guard let item = resolvedCurrentSelectedItem else {
|
||||
return
|
||||
}
|
||||
|
||||
revealInFinder(item)
|
||||
},
|
||||
shareAction: { anchorView in
|
||||
guard let item = currentSelectedItem else {
|
||||
guard let item = resolvedCurrentSelectedItem else {
|
||||
return
|
||||
}
|
||||
|
||||
@ -119,7 +145,7 @@ struct ContentView: View {
|
||||
.frame(minWidth: 450)
|
||||
}
|
||||
.overlay {
|
||||
if library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty {
|
||||
if library.isRestoringPersistedSources && isEmptyLibrary {
|
||||
LaunchRestoreOverlayView()
|
||||
}
|
||||
}
|
||||
@ -138,60 +164,46 @@ struct ContentView: View {
|
||||
.task {
|
||||
AppTerminationCoordinator.shared.register(library: library)
|
||||
}
|
||||
.disabled(library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty)
|
||||
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
||||
.disabled(library.isRestoringPersistedSources && isEmptyLibrary)
|
||||
.onChange(of: resolvedDisplayedItems.map(\.id)) { _, filteredIDs in
|
||||
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.selectedItemID = nil
|
||||
}
|
||||
.onChange(of: selectedSidebarSelection) { _, selection in
|
||||
guard let selection else {
|
||||
return
|
||||
}
|
||||
|
||||
if case .source = selection {
|
||||
selectedItemID = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: library.sources.map(\.id)) { _, _ in
|
||||
syncSelection(with: library.visibleSources.map(\.id))
|
||||
}
|
||||
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
|
||||
syncSelection(with: library.visibleSources.map(\.id))
|
||||
}
|
||||
.task(id: currentSelectedItem?.id) {
|
||||
.task(id: currentProjectionRequest) {
|
||||
let request = currentProjectionRequest
|
||||
let projection = await Task.detached(priority: .userInitiated) {
|
||||
ItemCollectionProjector.makeProjection(for: request)
|
||||
}.value
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
itemListProjection = projection
|
||||
}
|
||||
.task(id: resolvedCurrentSelectedItem?.id) {
|
||||
await refreshDirectoryPreviewContents()
|
||||
}
|
||||
}
|
||||
|
||||
private var scopedItems: [MinecraftContentItem] {
|
||||
guard let selectedSidebarSelection else {
|
||||
return []
|
||||
}
|
||||
private var sidebarSelectionBinding: Binding<SidebarSelection?> {
|
||||
Binding(
|
||||
get: { selectedSidebarSelection },
|
||||
set: { newSelection in
|
||||
if newSelection != selectedSidebarSelection {
|
||||
selectedItemID = nil
|
||||
}
|
||||
|
||||
switch selectedSidebarSelection {
|
||||
case .source(let sourceID), .allContent(let sourceID):
|
||||
return library.source(withID: sourceID)?.items ?? []
|
||||
case .contentType(let sourceID, let contentType):
|
||||
return library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredItems: [MinecraftContentItem] {
|
||||
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedSearchText.isEmpty else {
|
||||
return scopedItems
|
||||
}
|
||||
|
||||
return scopedItems.filter { item in
|
||||
item.searchText.localizedCaseInsensitiveContains(trimmedSearchText)
|
||||
}
|
||||
}
|
||||
|
||||
private var displayedItems: [MinecraftContentItem] {
|
||||
filteredItems.sorted(by: sortComparator)
|
||||
selectedSidebarSelection = newSelection
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var currentSource: MinecraftSource? {
|
||||
@ -202,14 +214,23 @@ struct ContentView: View {
|
||||
return library.source(withID: sourceID)
|
||||
}
|
||||
|
||||
private var currentSelectedItem: MinecraftContentItem? {
|
||||
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
|
||||
guard let selectedItemID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return library.visibleSources
|
||||
.flatMap(\.items)
|
||||
.first(where: { $0.id == selectedItemID })
|
||||
if let source,
|
||||
let item = source.items.first(where: { $0.id == selectedItemID }) {
|
||||
return item
|
||||
}
|
||||
|
||||
for source in library.visibleSources {
|
||||
if let item = source.items.first(where: { $0.id == selectedItemID }) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var sortComparator: (MinecraftContentItem, MinecraftContentItem) -> Bool {
|
||||
@ -255,84 +276,14 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var collectionHeaderTitle: String {
|
||||
if isSearching {
|
||||
return "Searching “\(searchScopeTitle)”"
|
||||
}
|
||||
|
||||
guard let selectedSidebarSelection else {
|
||||
return "Library"
|
||||
}
|
||||
|
||||
switch selectedSidebarSelection {
|
||||
case .source, .allContent:
|
||||
return "All Items"
|
||||
case .contentType(_, let contentType):
|
||||
return sidebarTitle(for: contentType)
|
||||
}
|
||||
}
|
||||
|
||||
private var collectionHeaderSubtitle: String {
|
||||
let totalCount = scopedItems.count
|
||||
let filteredCount = filteredItems.count
|
||||
let noun = collectionCountNoun
|
||||
|
||||
if !isSearching {
|
||||
return "\(totalCount.formatted(.number)) \(noun)"
|
||||
}
|
||||
|
||||
return "\(filteredCount.formatted(.number)) of \(totalCount.formatted(.number)) \(noun)"
|
||||
}
|
||||
|
||||
private var currentSourceDisplayName: String {
|
||||
currentSource?.displayName ?? "Library"
|
||||
}
|
||||
|
||||
private var currentCollectionStatus: String? {
|
||||
guard let currentSource else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if currentSource.isScanning {
|
||||
return currentSource.scanStatus
|
||||
}
|
||||
|
||||
if let scanError = currentSource.scanError, !scanError.isEmpty {
|
||||
return scanError
|
||||
}
|
||||
|
||||
if !currentSource.scanStatus.isEmpty {
|
||||
return currentSource.scanStatus
|
||||
}
|
||||
|
||||
if let lastScanDate = currentSource.lastScanDate {
|
||||
return "Last scanned \(lastScanDate.formatted(date: .abbreviated, time: .shortened))"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var areCurrentItemFileActionsEnabled: Bool {
|
||||
guard currentSelectedItem != nil else {
|
||||
guard currentSelectedItem(in: currentSource) != nil else {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentSource?.availability == .available
|
||||
}
|
||||
|
||||
private var searchScopeTitle: String {
|
||||
switch selectedSidebarSelection {
|
||||
case .some(.source(let sourceID)):
|
||||
return library.source(withID: sourceID)?.displayName ?? "Library"
|
||||
case .some(.allContent):
|
||||
return "All"
|
||||
case .some(.contentType(_, let contentType)):
|
||||
return sidebarTitle(for: contentType)
|
||||
case .none:
|
||||
return "Library"
|
||||
}
|
||||
}
|
||||
|
||||
private var isSearching: Bool {
|
||||
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
@ -345,42 +296,9 @@ struct ContentView: View {
|
||||
isSearching || !isSidebarVisible
|
||||
}
|
||||
|
||||
private var collectionCountNoun: String {
|
||||
guard let selectedSidebarSelection else {
|
||||
return "items"
|
||||
}
|
||||
|
||||
switch selectedSidebarSelection {
|
||||
case .source, .allContent:
|
||||
return scopedItems.count == 1 ? "item" : "items"
|
||||
case .contentType(_, let contentType):
|
||||
switch contentType {
|
||||
case .world:
|
||||
return scopedItems.count == 1 ? "world" : "worlds"
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
return scopedItems.count == 1 ? "pack" : "packs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var searchPrompt: String {
|
||||
switch selectedSidebarSelection {
|
||||
case .some(.source(let sourceID)):
|
||||
let sourceName = library.source(withID: sourceID)?.displayName ?? "Library"
|
||||
return "Search \(sourceName)"
|
||||
case .some(.allContent):
|
||||
return "Search All Items"
|
||||
case .some(.contentType(_, let contentType)):
|
||||
return "Search \(sidebarTitle(for: contentType))"
|
||||
case .none:
|
||||
return "Search Library"
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
||||
MinecraftContentType.allCases.compactMap { contentType in
|
||||
let count = source.items.filter { $0.contentType == contentType }.count
|
||||
guard count > 0 else {
|
||||
return MinecraftContentType.allCases.compactMap { contentType in
|
||||
guard let count = source.displayItemCountsByType[contentType], count > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -539,7 +457,8 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func refreshDirectoryPreviewContents() async {
|
||||
guard let item = currentSelectedItem, let source = currentSource else {
|
||||
guard let source = currentSource,
|
||||
let item = currentSelectedItem(in: source) else {
|
||||
await MainActor.run {
|
||||
directoryPreviewContents = []
|
||||
}
|
||||
@ -615,7 +534,7 @@ struct ContentView: View {
|
||||
selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) }
|
||||
}
|
||||
|
||||
if let selectedItemID, currentSelectedItem?.id != selectedItemID {
|
||||
if let selectedItemID, currentSelectedItem(in: currentSource)?.id != selectedItemID {
|
||||
self.selectedItemID = nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
import Foundation
|
||||
|
||||
struct ItemCollectionProjectionRequest: Hashable, Sendable {
|
||||
let selection: SidebarSelection?
|
||||
let searchText: String
|
||||
let sortMode: ItemSortMode
|
||||
let source: MinecraftSource?
|
||||
}
|
||||
|
||||
struct ItemCollectionProjection: Sendable {
|
||||
let request: ItemCollectionProjectionRequest
|
||||
let sourceName: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let searchPrompt: String
|
||||
let items: [MinecraftContentItem]
|
||||
|
||||
nonisolated static func placeholder(for request: ItemCollectionProjectionRequest) -> ItemCollectionProjection {
|
||||
ItemCollectionProjection(
|
||||
request: request,
|
||||
sourceName: request.source?.displayName ?? "Library",
|
||||
title: ItemCollectionProjector.title(
|
||||
for: request.selection,
|
||||
isSearching: !ItemCollectionProjector.trimmedSearchText(for: request).isEmpty
|
||||
),
|
||||
subtitle: "",
|
||||
searchPrompt: ItemCollectionProjector.searchPrompt(for: request.selection, source: request.source),
|
||||
items: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum ItemCollectionProjector {
|
||||
nonisolated static func makeProjection(for request: ItemCollectionProjectionRequest) -> ItemCollectionProjection {
|
||||
let trimmedSearchText = trimmedSearchText(for: request)
|
||||
let scopedItems = request.source?.items(matching: request.selection) ?? []
|
||||
let filteredItems: [MinecraftContentItem]
|
||||
|
||||
if trimmedSearchText.isEmpty {
|
||||
filteredItems = scopedItems
|
||||
} else {
|
||||
filteredItems = scopedItems.filter { item in
|
||||
item.searchText.localizedCaseInsensitiveContains(trimmedSearchText)
|
||||
}
|
||||
}
|
||||
|
||||
let displayedItems = filteredItems.sorted(by: sortComparator(for: request.sortMode))
|
||||
let countNoun = collectionCountNoun(for: request.selection, scopedItemCount: scopedItems.count)
|
||||
let subtitle: String
|
||||
|
||||
if trimmedSearchText.isEmpty {
|
||||
subtitle = "\(scopedItems.count.formatted(.number)) \(countNoun)"
|
||||
} else {
|
||||
subtitle = "\(displayedItems.count.formatted(.number)) of \(scopedItems.count.formatted(.number)) \(countNoun)"
|
||||
}
|
||||
|
||||
return ItemCollectionProjection(
|
||||
request: request,
|
||||
sourceName: request.source?.displayName ?? "Library",
|
||||
title: title(for: request.selection, isSearching: !trimmedSearchText.isEmpty),
|
||||
subtitle: subtitle,
|
||||
searchPrompt: searchPrompt(for: request.selection, source: request.source),
|
||||
items: displayedItems
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated static func title(for selection: SidebarSelection?, isSearching: Bool) -> String {
|
||||
if isSearching {
|
||||
return "Searching “\(searchScopeTitle(for: selection))”"
|
||||
}
|
||||
|
||||
guard let selection else {
|
||||
return "Library"
|
||||
}
|
||||
|
||||
switch selection {
|
||||
case .source, .allContent:
|
||||
return "All Items"
|
||||
case .contentType(_, let contentType):
|
||||
return sidebarTitle(for: contentType)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
|
||||
switch selection {
|
||||
case .some(.source):
|
||||
return "Search \(source?.displayName ?? "Library")"
|
||||
case .some(.allContent):
|
||||
return "Search All Items"
|
||||
case .some(.contentType(_, let contentType)):
|
||||
return "Search \(sidebarTitle(for: contentType))"
|
||||
case .none:
|
||||
return "Search Library"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
|
||||
switch selection {
|
||||
case .some(.source):
|
||||
return "Library"
|
||||
case .some(.allContent):
|
||||
return "All"
|
||||
case .some(.contentType(_, let contentType)):
|
||||
return sidebarTitle(for: contentType)
|
||||
case .none:
|
||||
return "Library"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func collectionCountNoun(for selection: SidebarSelection?, scopedItemCount: Int) -> String {
|
||||
guard let selection else {
|
||||
return "items"
|
||||
}
|
||||
|
||||
switch selection {
|
||||
case .source, .allContent:
|
||||
return scopedItemCount == 1 ? "item" : "items"
|
||||
case .contentType(_, let contentType):
|
||||
switch contentType {
|
||||
case .world:
|
||||
return scopedItemCount == 1 ? "world" : "worlds"
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
return scopedItemCount == 1 ? "pack" : "packs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func sidebarTitle(for contentType: MinecraftContentType) -> String {
|
||||
switch contentType {
|
||||
case .world:
|
||||
return "Worlds"
|
||||
case .behaviorPack:
|
||||
return "Behavior Packs"
|
||||
case .resourcePack:
|
||||
return "Resource Packs"
|
||||
case .skinPack:
|
||||
return "Skin Packs"
|
||||
case .worldTemplate:
|
||||
return "World Templates"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String {
|
||||
request.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
nonisolated private static func sortComparator(for mode: ItemSortMode) -> (MinecraftContentItem, MinecraftContentItem) -> Bool {
|
||||
switch mode {
|
||||
case .name:
|
||||
return { lhs, rhs in
|
||||
lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
|
||||
}
|
||||
case .modifiedDate:
|
||||
return { lhs, rhs in
|
||||
switch (lhs.displayDate, rhs.displayDate) {
|
||||
case let (lhsDate?, rhsDate?):
|
||||
if lhsDate != rhsDate {
|
||||
return lhsDate > rhsDate
|
||||
}
|
||||
case (.some, nil):
|
||||
return true
|
||||
case (nil, .some):
|
||||
return false
|
||||
case (nil, nil):
|
||||
break
|
||||
}
|
||||
|
||||
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
|
||||
}
|
||||
case .size:
|
||||
return { lhs, rhs in
|
||||
switch (lhs.sizeBytes, rhs.sizeBytes) {
|
||||
case let (lhsSize?, rhsSize?):
|
||||
if lhsSize != rhsSize {
|
||||
return lhsSize > rhsSize
|
||||
}
|
||||
case (.some, nil):
|
||||
return true
|
||||
case (nil, .some):
|
||||
return false
|
||||
case (nil, nil):
|
||||
break
|
||||
}
|
||||
|
||||
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,19 +2,18 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
enum AppChrome {
|
||||
static let sidebarRowCornerRadius: CGFloat = 10
|
||||
static let placeholderCardCornerRadius: CGFloat = 16
|
||||
static let panelCardCornerRadius: CGFloat = 18
|
||||
static let detailSectionCardPadding: CGFloat = 18
|
||||
}
|
||||
|
||||
enum AppCardStyle {
|
||||
case primaryPanel
|
||||
case secondaryPanel
|
||||
case detailPanel
|
||||
case placeholder
|
||||
|
||||
fileprivate var cornerRadius: CGFloat {
|
||||
switch self {
|
||||
case .primaryPanel, .secondaryPanel:
|
||||
case .detailPanel:
|
||||
return AppChrome.panelCardCornerRadius
|
||||
case .placeholder:
|
||||
return AppChrome.placeholderCardCornerRadius
|
||||
@ -23,9 +22,7 @@ enum AppCardStyle {
|
||||
|
||||
fileprivate var fillStyle: AnyShapeStyle {
|
||||
switch self {
|
||||
case .primaryPanel:
|
||||
return AnyShapeStyle(.regularMaterial)
|
||||
case .secondaryPanel:
|
||||
case .detailPanel:
|
||||
return AnyShapeStyle(.quaternary.opacity(0.32))
|
||||
case .placeholder:
|
||||
return AnyShapeStyle(.thinMaterial)
|
||||
@ -44,19 +41,213 @@ private struct AppCardSurfaceModifier: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
enum AppSectionTitleStyle {
|
||||
case section
|
||||
case overline
|
||||
}
|
||||
|
||||
enum AppTextStyle {
|
||||
case rowTitle
|
||||
case supporting
|
||||
case supportingCompact
|
||||
case fieldLabel
|
||||
case emphasisLabel
|
||||
}
|
||||
|
||||
enum AppActivityIndicatorStyle {
|
||||
case small
|
||||
case large
|
||||
}
|
||||
|
||||
private struct AppSectionTitleModifier: ViewModifier {
|
||||
let style: AppSectionTitleStyle
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
switch style {
|
||||
case .section:
|
||||
content
|
||||
.font(.headline)
|
||||
case .overline:
|
||||
content
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppTextStyleModifier: ViewModifier {
|
||||
let style: AppTextStyle
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
switch style {
|
||||
case .rowTitle:
|
||||
content
|
||||
.font(.subheadline.weight(.semibold))
|
||||
case .supporting:
|
||||
content
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
case .supportingCompact:
|
||||
content
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
case .fieldLabel:
|
||||
content
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .emphasisLabel:
|
||||
content
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppDetailSectionCardModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppChrome.detailSectionCardPadding)
|
||||
.appCardSurface(.detailPanel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppActivityIndicatorModifier: ViewModifier {
|
||||
let style: AppActivityIndicatorStyle
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
switch style {
|
||||
case .small:
|
||||
content.controlSize(.small)
|
||||
case .large:
|
||||
content.controlSize(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppListHeaderSurfaceModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppMiniProminentButtonModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppTransportBadgeBubbleModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(4)
|
||||
.background(.thinMaterial, in: Circle())
|
||||
}
|
||||
}
|
||||
|
||||
enum AppCapsuleLabelStyle {
|
||||
case sidebarSubtle
|
||||
case sidebarAccent
|
||||
case heroMetadata
|
||||
}
|
||||
|
||||
private struct AppCapsuleLabelModifier: ViewModifier {
|
||||
let style: AppCapsuleLabelStyle
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(foregroundStyle)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
.background(backgroundStyle, in: Capsule())
|
||||
}
|
||||
|
||||
private var foregroundStyle: AnyShapeStyle {
|
||||
switch style {
|
||||
case .sidebarSubtle:
|
||||
return AnyShapeStyle(.secondary)
|
||||
case .sidebarAccent:
|
||||
return AnyShapeStyle(Color.appAccent)
|
||||
case .heroMetadata:
|
||||
return AnyShapeStyle(.white.opacity(0.95))
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundStyle: AnyShapeStyle {
|
||||
switch style {
|
||||
case .sidebarSubtle:
|
||||
return AnyShapeStyle(.secondary.opacity(0.12))
|
||||
case .sidebarAccent:
|
||||
return AnyShapeStyle(Color.appAccent.opacity(0.14))
|
||||
case .heroMetadata:
|
||||
return AnyShapeStyle(.white.opacity(0.14))
|
||||
}
|
||||
}
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
switch style {
|
||||
case .heroMetadata:
|
||||
return 10
|
||||
case .sidebarSubtle, .sidebarAccent:
|
||||
return 7
|
||||
}
|
||||
}
|
||||
|
||||
private var verticalPadding: CGFloat {
|
||||
switch style {
|
||||
case .heroMetadata:
|
||||
return 7
|
||||
case .sidebarSubtle, .sidebarAccent:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appCardSurface(_ style: AppCardStyle) -> some View {
|
||||
modifier(AppCardSurfaceModifier(style: style))
|
||||
}
|
||||
|
||||
func appSidebarRowSurface(isHighlighted: Bool) -> some View {
|
||||
let fillStyle = isHighlighted
|
||||
? AnyShapeStyle(.secondary.opacity(0.08))
|
||||
: AnyShapeStyle(Color.clear)
|
||||
return background(
|
||||
fillStyle,
|
||||
in: RoundedRectangle(cornerRadius: AppChrome.sidebarRowCornerRadius, style: .continuous)
|
||||
)
|
||||
func appSectionTitleStyle(_ style: AppSectionTitleStyle) -> some View {
|
||||
modifier(AppSectionTitleModifier(style: style))
|
||||
}
|
||||
|
||||
func appTextStyle(_ style: AppTextStyle) -> some View {
|
||||
modifier(AppTextStyleModifier(style: style))
|
||||
}
|
||||
|
||||
func appActivityIndicatorStyle(_ style: AppActivityIndicatorStyle) -> some View {
|
||||
modifier(AppActivityIndicatorModifier(style: style))
|
||||
}
|
||||
|
||||
func appDetailSectionCard() -> some View {
|
||||
modifier(AppDetailSectionCardModifier())
|
||||
}
|
||||
|
||||
func appCapsuleLabelStyle(_ style: AppCapsuleLabelStyle) -> some View {
|
||||
modifier(AppCapsuleLabelModifier(style: style))
|
||||
}
|
||||
|
||||
func appListHeaderSurface() -> some View {
|
||||
modifier(AppListHeaderSurfaceModifier())
|
||||
}
|
||||
|
||||
func appMiniProminentButton() -> some View {
|
||||
modifier(AppMiniProminentButtonModifier())
|
||||
}
|
||||
|
||||
func appTransportBadgeBubble() -> some View {
|
||||
modifier(AppTransportBadgeBubbleModifier())
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,11 +488,7 @@ struct RecordHeroView: View {
|
||||
private var recordHeroChips: some View {
|
||||
FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in
|
||||
Text(chip)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.95))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(.white.opacity(0.14), in: Capsule())
|
||||
.appCapsuleLabelStyle(.heroMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@ -391,7 +578,7 @@ struct LaunchRestoreOverlayView: View {
|
||||
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.appActivityIndicatorStyle(.large)
|
||||
|
||||
Text("Opening Library…")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SidebarSelection: Hashable {
|
||||
enum SidebarSelection: Hashable, Sendable {
|
||||
case source(sourceID: URL)
|
||||
case allContent(sourceID: URL)
|
||||
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
||||
@ -78,14 +78,13 @@ struct SourcesSidebarView: View {
|
||||
|
||||
SourceHeaderRow(
|
||||
source: source,
|
||||
isSelected: selection == .source(sourceID: source.id),
|
||||
onSelect: {
|
||||
selection = .source(sourceID: source.id)
|
||||
}
|
||||
)
|
||||
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 8))
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 0))
|
||||
.contextMenu {
|
||||
Button("Rescan \"\(source.displayName)\"") {
|
||||
rescanSourceAction(source)
|
||||
@ -143,54 +142,49 @@ private struct SidebarSourcesSectionHeaderView: View {
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceHeaderRow: View {
|
||||
let source: MinecraftSource
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: headerSymbolName)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(source.displayName)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: headerSymbolName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(titleColor)
|
||||
|
||||
Text(source.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(titleColor)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
if let availabilityBadgeText {
|
||||
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
|
||||
}
|
||||
|
||||
if let connection {
|
||||
SourceConnectionBadge(connection: connection)
|
||||
}
|
||||
|
||||
if let availabilityBadgeText {
|
||||
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
|
||||
}
|
||||
|
||||
if showsStatusIndicator {
|
||||
statusIndicator
|
||||
.frame(width: 24, height: 24)
|
||||
if showsStatusAccessory {
|
||||
statusAccessory
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.appSidebarRowSurface(isHighlighted: isHovering && !isSelected)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 5)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { isHovering = $0 }
|
||||
.onTapGesture(perform: onSelect)
|
||||
}
|
||||
|
||||
private var connection: DeviceConnection? {
|
||||
guard source.availability == .available else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard case .connectedDevice(let device, _) = source.origin else {
|
||||
return nil
|
||||
}
|
||||
@ -207,14 +201,6 @@ private struct SourceHeaderRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var titleColor: Color {
|
||||
if source.availability != .available && !isSelected {
|
||||
return .secondary
|
||||
}
|
||||
|
||||
return isSelected ? Color.primary : .secondary
|
||||
}
|
||||
|
||||
private var availabilityBadgeText: String? {
|
||||
if source.isOfflineCached {
|
||||
return "Cached"
|
||||
@ -233,33 +219,22 @@ private struct SourceHeaderRow: View {
|
||||
private var availabilityBadgeEmphasis: Bool {
|
||||
source.availability == .limited
|
||||
}
|
||||
@ViewBuilder
|
||||
private var statusIndicator: some View {
|
||||
if source.isScanning {
|
||||
if let scanProgress = source.scanProgress {
|
||||
CircularScanProgressView(progress: scanProgress)
|
||||
} else {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if source.availability == .limited {
|
||||
Image(systemName: "lock.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if source.availability != .available {
|
||||
Image(systemName: source.isOfflineCached ? "externaldrive.badge.exclamationmark" : "slash.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if source.scanError != nil {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var showsStatusAccessory: Bool {
|
||||
source.isScanning
|
||||
}
|
||||
|
||||
private var showsStatusIndicator: Bool {
|
||||
source.isScanning || source.scanError != nil || source.availability != .available
|
||||
}
|
||||
@ViewBuilder
|
||||
private var statusAccessory: some View {
|
||||
if source.isScanning {
|
||||
if let scanProgress = source.scanProgress {
|
||||
CircularScanProgressView(progress: scanProgress)
|
||||
} else {
|
||||
ProgressView()
|
||||
.appActivityIndicatorStyle(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceConnectionBadge: View {
|
||||
@ -267,11 +242,7 @@ private struct SourceConnectionBadge: View {
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: symbolName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(.secondary.opacity(0.12), in: Capsule())
|
||||
.appCapsuleLabelStyle(.sidebarSubtle)
|
||||
.help(helpText)
|
||||
.accessibilityLabel(helpText)
|
||||
}
|
||||
@ -301,15 +272,7 @@ private struct SourceAvailabilityBadge: View {
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(emphasis ? Color.appAccent : .secondary)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(backgroundColor, in: Capsule())
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
emphasis ? Color.appAccent.opacity(0.14) : .secondary.opacity(0.12)
|
||||
.appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle)
|
||||
}
|
||||
}
|
||||
|
||||
@ -350,12 +313,11 @@ private struct ConnectedDeviceRow: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.device.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.appTextStyle(.rowTitle)
|
||||
.foregroundStyle(titleColor)
|
||||
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.appTextStyle(.supportingCompact)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
@ -364,8 +326,7 @@ private struct ConnectedDeviceRow: View {
|
||||
Button("Add") {
|
||||
addAction()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.appMiniProminentButton()
|
||||
}
|
||||
}
|
||||
.opacity(addAction == nil ? 0.68 : 1)
|
||||
@ -429,10 +390,7 @@ private struct ConnectedDeviceTransportIcon: View {
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: badgeSymbolName)
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(4)
|
||||
.background(.thinMaterial, in: Circle())
|
||||
.appTransportBadgeBubble()
|
||||
.offset(x: 4, y: 4)
|
||||
}
|
||||
.help(helpText)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user