world-manager/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift

427 lines
12 KiB
Swift

import SwiftUI
enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL)
case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType)
var sourceID: URL {
switch self {
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _):
return sourceID
}
}
}
struct SidebarFilter: Identifiable, Hashable {
var id: SidebarSelection { selection }
let title: String
let iconName: String
let count: Int
let selection: SidebarSelection
}
struct SourcesSidebarView: View {
let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry]
@Binding var selection: SidebarSelection?
let addSourceAction: () -> Void
let addDeviceSourceAction: () -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
let rescanSourceAction: (MinecraftSource) -> Void
let removeSourceAction: (MinecraftSource) -> Void
let filters: (MinecraftSource) -> [SidebarFilter]
var body: some View {
List(selection: $selection) {
if !sources.isEmpty {
Section {
ForEach(sources) { source in
sourceSectionRows(for: source)
}
} header: {
SidebarSourcesSectionHeaderView(title: "Libraries")
}
}
if !connectedDevices.isEmpty {
Section {
ForEach(connectedDevices) { entry in
connectedDeviceSectionRows(for: entry)
}
} header: {
SidebarSourcesSectionHeaderView(title: "Available Devices")
}
}
}
.listStyle(.sidebar)
.toolbar {
ToolbarItem {
Button(action: addSourceAction) {
Image(systemName: "folder.badge.plus")
}
.help("Add Source Folder")
}
ToolbarItem {
Button(action: addDeviceSourceAction) {
Image(systemName: "iphone.gen3")
}
.help("Add Connected Device Source")
}
}
}
@ViewBuilder
private func sourceSectionRows(for source: MinecraftSource) -> some View {
let sourceFilters = filters(source)
SourceHeaderRow(
source: source,
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: 0))
.contextMenu {
Button("Rescan \"\(source.displayName)\"") {
rescanSourceAction(source)
}
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSourceAction(source)
}
}
ForEach(sourceFilters) { filter in
SidebarFilterRow(filter: filter, isIndented: true)
.tag(filter.selection as SidebarSelection?)
}
}
@ViewBuilder
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
ConnectedDeviceRow(
entry: entry,
addAction: entry.hasMinecraftContainer ? {
addConnectedDeviceAction(entry)
} : nil
)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
}
}
private struct SidebarFilterRow: View {
let filter: SidebarFilter
let isIndented: Bool
var body: some View {
HStack(spacing: 10) {
Image(systemName: filter.iconName)
.frame(width: 16)
.foregroundStyle(.secondary)
Text(filter.title)
Spacer()
Text(filter.count, format: .number)
.foregroundStyle(.secondary)
}
.padding(.leading, isIndented ? 16 : 0)
}
}
private struct SidebarSourcesSectionHeaderView: View {
let title: String
var body: some View {
Text(title)
}
}
private struct SourceHeaderRow: View {
let source: MinecraftSource
let onSelect: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: headerSymbolName)
.foregroundStyle(.secondary)
Text(source.displayName)
.lineLimit(1)
Spacer(minLength: 8)
HStack(spacing: 8) {
if let availabilityBadgeText {
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
}
if let connection {
SourceConnectionBadge(connection: connection)
}
if showsStatusAccessory {
statusAccessory
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 5)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
private var connection: DeviceConnection? {
guard source.availability == .available else {
return nil
}
guard case .connectedDevice(let device, _) = source.origin else {
return nil
}
return device.connection
}
private var headerSymbolName: String {
switch source.origin {
case .localFolder:
return "folder"
case .connectedDevice:
return "iphone.gen3"
}
}
private var availabilityBadgeText: String? {
if source.isOfflineCached {
return "Cached"
}
switch source.availability {
case .limited:
return "Limited"
case .unavailable, .disconnected:
return "Offline"
case .available, .unknown:
return nil
}
}
private var availabilityBadgeEmphasis: Bool {
source.availability == .limited
}
private var showsStatusAccessory: Bool {
source.isScanning
}
@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 {
let connection: DeviceConnection
var body: some View {
Image(systemName: symbolName)
.appCapsuleLabelStyle(.sidebarSubtle)
.help(helpText)
.accessibilityLabel(helpText)
}
private var symbolName: String {
switch connection {
case .usb:
return "cable.connector"
case .network:
return "wifi"
}
}
private var helpText: String {
switch connection {
case .usb:
return "USB"
case .network:
return "Network"
}
}
}
private struct SourceAvailabilityBadge: View {
let text: String
let emphasis: Bool
var body: some View {
Text(text)
.appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle)
}
}
private struct CircularScanProgressView: View {
let progress: Double
var body: some View {
ZStack {
Circle()
.stroke(.secondary.opacity(0.18), lineWidth: 3)
Circle()
.trim(from: 0, to: max(0.02, min(progress, 1)))
.stroke(
Color.appAccent,
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.rotationEffect(.degrees(-90))
}
.frame(width: 18, height: 18)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Scan progress")
.accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
}
}
private struct ConnectedDeviceRow: View {
let entry: ConnectedDeviceSidebarEntry
let addAction: (() -> Void)?
var body: some View {
HStack(alignment: .top, spacing: 10) {
ConnectedDeviceTransportIcon(
baseSymbolName: iconName,
connection: entry.device.connection,
tint: iconColor
)
VStack(alignment: .leading, spacing: 4) {
Text(entry.device.name)
.appTextStyle(.rowTitle)
.foregroundStyle(titleColor)
Text(statusText)
.appTextStyle(.supportingCompact)
}
Spacer(minLength: 12)
if let addAction {
Button("Add") {
addAction()
}
.appMiniProminentButton()
}
}
.opacity(addAction == nil ? 0.68 : 1)
}
private var iconName: String {
if entry.hasMinecraftContainer {
return "iphone.gen3"
}
switch entry.device.trustState {
case .trusted:
return "iphone.slash"
case .locked, .untrusted:
return "lock.iphone"
case .unavailable:
return "iphone.gen3.slash"
}
}
private var iconColor: Color {
entry.hasMinecraftContainer ? .appAccent : .secondary
}
private var titleColor: Color {
addAction == nil ? .secondary : .primary
}
private var statusText: String {
if let errorDescription = entry.discoveryErrorDescription, !errorDescription.isEmpty {
return errorDescription
}
switch entry.device.trustState {
case .trusted:
if entry.hasMinecraftContainer {
return "Ready to add Minecraft library"
}
return "No Minecraft source found"
case .locked:
return "Unlock this device to inspect apps"
case .untrusted:
return "Trust this device to inspect apps"
case .unavailable:
return "Device unavailable"
}
}
}
private struct ConnectedDeviceTransportIcon: View {
let baseSymbolName: String
let connection: DeviceConnection
let tint: Color
var body: some View {
ZStack(alignment: .bottomTrailing) {
Image(systemName: baseSymbolName)
.font(.system(size: 22, weight: .medium))
.foregroundStyle(tint)
.frame(width: 28, height: 28)
Image(systemName: badgeSymbolName)
.appTransportBadgeBubble()
.offset(x: 4, y: 4)
}
.help(helpText)
.accessibilityElement(children: .ignore)
.accessibilityLabel(helpText)
}
private var badgeSymbolName: String {
switch connection {
case .usb:
return "cable.connector"
case .network:
return "wifi"
}
}
private var helpText: String {
switch connection {
case .usb:
return "Connected by USB"
case .network:
return "Connected by Network"
}
}
}
#if DEBUG
struct SidebarColumnViews_Previews: PreviewProvider {
static var previews: some View {
SidebarColumnPreviewContainer()
}
}
#endif