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