ios: reorderable tab bar with more tabs, debug log export, AirPlay picker, more room above seeker

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-20 14:11:58 -07:00
1 parent a0fe35a8d8
commit 926ad6ee99
10 files changed
+350 -10

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import AVFoundation
import Foundation
import SwiftUI
import UIKit
/// App log kept in a size-capped file so a tester can send it. Tokens are stripped before anything is written.
final class Log: @unchecked Sendable {
static let shared = Log()
enum Level: String { case debug = "D", info = "I", error = "E" }
static let verboseKey = "verboseLogging"
static var verbose: Bool {
get { UserDefaults.standard.bool(forKey: verboseKey) }
set { UserDefaults.standard.set(newValue, forKey: verboseKey) }
}
private let lock = NSLock()
private let url: URL
private let maxBytes = 512 * 1024
private let stamp: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MM-dd HH:mm:ss.SSS"
return f
}()
private init() {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
url = dir.appendingPathComponent("stemify.log")
}
private static let secret = try! NSRegularExpression(
pattern: #"(X-Plex-Token|X-Stem-Token|token|Authorization)([=:"' ]+)[^&\s"',}]+"#, options: [.caseInsensitive])
static func redact(_ s: String) -> String {
secret.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1$2<redacted>")
}
func write(_ level: Level, _ tag: String, _ msg: String) {
if level == .debug && !Log.verbose { return }
let line = "\(stamp.string(from: Date())) \(level.rawValue) [\(tag)] \(Log.redact(msg))\n"
lock.lock(); defer { lock.unlock() }
guard let data = line.data(using: .utf8) else { return }
let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0
if size > maxBytes { trim() }
if FileManager.default.fileExists(atPath: url.path) { append(data) } else { try? data.write(to: url) }
}
/// Keeps the newest half of the file.
private func trim() {
guard let all = try? Data(contentsOf: url) else { return }
let tail = all.suffix(maxBytes / 2)
let cut = tail.firstIndex(of: 0x0A).map { tail.index(after: $0) } ?? tail.startIndex
try? Data(tail[cut...]).write(to: url)
}
private func append(_ data: Data) {
guard let h = try? FileHandle(forWritingTo: url) else { return }
defer { try? h.close() }
_ = try? h.seekToEnd()
try? h.write(contentsOf: data)
}
func text() -> String {
lock.lock(); defer { lock.unlock() }
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
}
func clear() {
lock.lock(); defer { lock.unlock() }
try? FileManager.default.removeItem(at: url)
}
/// Device and app details followed by the log, written to a temp file for the share sheet.
func export() -> URL {
let b = Bundle.main
let d = UIDevice.current
var head = "Stemify \(b.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?") "
head += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n"
head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n"
head += "Verbose logging: \(Log.verbose ? "on" : "off")\n"
head += "Output: \(AirRoute.describe())\n\n"
let out = FileManager.default.temporaryDirectory.appendingPathComponent("stemify-log.txt")
try? (head + text()).write(to: out, atomically: true, encoding: .utf8)
return out
}
func debug(_ tag: String, _ msg: @autoclosure () -> String) { if Log.verbose { write(.debug, tag, msg()) } }
func info(_ tag: String, _ msg: String) { write(.info, tag, msg) }
func error(_ tag: String, _ msg: String) { write(.error, tag, msg) }
}
/// Where audio is currently going (speaker, AirPlay, Bluetooth…).
enum AirRoute {
static func describe() -> String {
let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { "\($0.portName) (\($0.portType.rawValue))" }
return outs.isEmpty ? "none" : outs.joined(separator: ", ")
}
}
+2 -1
View File
@@ -102,7 +102,7 @@ final class Settings: @unchecked Sendable {
private static let keys = [ private static let keys = [
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle", "plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate", "stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
] ]
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T { private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
@@ -175,6 +175,7 @@ final class Settings: @unchecked Sendable {
/// Plex path prefixes that matched the stems index, newline-separated. /// Plex path prefixes that matched the stems index, newline-separated.
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } } var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } }
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } } var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } } var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } } var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
+1 -1
View File
@@ -369,7 +369,7 @@ final class StemDecoder: @unchecked Sendable {
} }
} catch { } catch {
if !released { if !released {
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription) Log.shared.error("decoder", "\(name) failed: \(error.localizedDescription)")
state.with { _error = error } state.with { _error = error }
} }
ring.eos = true ring.eos = true
@@ -181,6 +181,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car). /// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
func report(_ text: String) { func report(_ text: String) {
Log.shared.error("report", text)
message = AppMessage(text: text) message = AppMessage(text: text)
messageListeners.values.forEach { $0(text) } messageListeners.values.forEach { $0(text) }
} }
@@ -797,6 +798,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let self, let info = n.userInfo, guard let self, let info = n.userInfo,
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt, let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")")
switch type { switch type {
case .began: case .began:
// A stale "began" arrives after the app was suspended; the session wasn't really taken. // A stale "began" arrives after the app was suspended; the session wasn't really taken.
@@ -813,12 +815,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return } let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
Log.shared.info("route", "change reason=\(reason.rawValue) now \(AirRoute.describe())")
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker. // Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() } if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
self.eq.refreshRoute() self.eq.refreshRoute()
} }
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
guard let self else { return } guard let self else { return }
Log.shared.error("session", "media services reset")
let was = self.playWhenReady let was = self.playWhenReady
let pos = self.positionMs() let pos = self.positionMs()
self.currentToken = -1 self.currentToken = -1
+20
View File
@@ -1,4 +1,6 @@
import AVKit
import SwiftUI import SwiftUI
import UIKit
let MARGIN: CGFloat = 20 let MARGIN: CGFloat = 20
@@ -330,3 +332,21 @@ private struct ListRow: ViewModifier {
} }
} }
} }
/// The system AirPlay / Bluetooth output picker. The mix is rendered on the phone, so stem edits keep working over AirPlay.
struct AirPlayButton: UIViewRepresentable {
var tint: UIColor = .label
var active: UIColor = .systemBlue
func makeUIView(context: Context) -> AVRoutePickerView {
let v = AVRoutePickerView()
v.prioritizesVideoDevices = false
v.backgroundColor = .clear
return v
}
func updateUIView(_ v: AVRoutePickerView, context: Context) {
v.tintColor = tint
v.activeTintColor = active
}
}
+43 -5
View File
@@ -1,11 +1,32 @@
import SwiftUI import SwiftUI
enum Tab: String, CaseIterable, Hashable { enum Tab: String, CaseIterable, Hashable {
case Library, Playlists, Search, Downloads, Settings case Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads, Settings
static let defaultLayout: [Tab] = [.Library, .Playlists, .Search, .Downloads, .Settings]
/// iOS folds anything past five tabs into "More", so the bar holds at most this many.
static let maxVisible = 5
/// Parses the saved comma list; Settings is always kept so the app can't lock itself out.
static func layout(_ raw: String) -> [Tab] {
var out: [Tab] = []
for part in raw.split(separator: ",") {
if let t = Tab(rawValue: String(part)), !out.contains(t) { out.append(t) }
}
if out.isEmpty { return defaultLayout }
if !out.contains(.Settings) { out = Array(out.prefix(maxVisible - 1)) + [.Settings] }
return Array(out.prefix(maxVisible))
}
static func encode(_ tabs: [Tab]) -> String { tabs.map(\.rawValue).joined(separator: ",") }
var icon: String { var icon: String {
switch self { switch self {
case .Library: "square.stack.fill" case .Library: "square.stack.fill"
case .Songs: "music.note"
case .Albums: "square.stack"
case .Artists: "music.mic"
case .Genres: "guitars.fill"
case .Playlists: "music.note.list" case .Playlists: "music.note.list"
case .Search: "magnifyingglass" case .Search: "magnifyingglass"
case .Downloads: "arrow.down.circle.fill" case .Downloads: "arrow.down.circle.fill"
@@ -15,10 +36,10 @@ enum Tab: String, CaseIterable, Hashable {
} }
enum Route: Hashable { enum Route: Hashable {
case Artists, Albums, Songs, Playlists case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
case PlexAccount, StemServer, Quality, Storage, Appearance case PlexAccount, StemServer, Quality, Storage, Appearance
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
} }
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell. /// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
@@ -47,13 +68,30 @@ final class Ui {
if t == tab { paths[t] = [] } else { tab = t } if t == tab { paths[t] = [] } else { tab = t }
} }
/// The tabs currently in the bar, in order.
var tabs: [Tab] { Tab.layout(AppGraph.shared.settings.tabLayout) }
/// Where library pages open: Library if it's in the bar, else the first tab that is.
private var home: Tab { tabs.contains(.Library) ? .Library : (tabs.first ?? .Settings) }
/// Downloads as its own tab, or pushed onto the current one when it's hidden.
func openDownloads() {
if tabs.contains(.Downloads) { selectTab(.Downloads) } else { push(.Downloads) }
}
/// Falls back to the first tab when the selected one was just removed from the bar.
func ensureValidTab() {
if !tabs.contains(tab) { tab = tabs.first ?? .Settings }
}
func openNowPlaying() { nowPlayingOpen = true } func openNowPlaying() { nowPlayingOpen = true }
/// Switches to the Library tab and pushes [r] there. /// Switches to the Library tab and pushes [r] there.
func showInLibrary(_ r: Route) { func showInLibrary(_ r: Route) {
nowPlayingOpen = false nowPlayingOpen = false
tab = .Library let t = home
paths[.Library, default: []].append(r) tab = t
paths[t, default: []].append(r)
} }
/// Switches to Settings and opens [r] (the Equalizer) there. /// Switches to Settings and opens [r] (the Equalizer) there.
+10 -1
View File
@@ -9,7 +9,7 @@ struct RootView: View {
let s = g.settings let s = g.settings
let p = Palette.of(s.theme, s.accent) let p = Palette.of(s.theme, s.accent)
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) { TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
ForEach(Tab.allCases, id: \.self) { t in ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
NavigationStack(path: ui.path(t)) { NavigationStack(path: ui.path(t)) {
root(t).miniPlayerRoom() root(t).miniPlayerRoom()
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() } .navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
@@ -20,6 +20,7 @@ struct RootView: View {
} }
} }
.modifier(GlassMiniPlayerHost()) .modifier(GlassMiniPlayerHost())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) { .sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen() NowPlayingScreen()
.overlayHost(ui.overlays, level: 1) .overlayHost(ui.overlays, level: 1)
@@ -41,6 +42,10 @@ struct RootView: View {
@ViewBuilder private func root(_ t: Tab) -> some View { @ViewBuilder private func root(_ t: Tab) -> some View {
switch t { switch t {
case .Library: LibraryHome() case .Library: LibraryHome()
case .Songs: SongsScreen()
case .Albums: AlbumsScreen()
case .Artists: ArtistsScreen()
case .Genres: GenresScreen()
case .Playlists: PlaylistsScreen() case .Playlists: PlaylistsScreen()
case .Search: SearchScreen() case .Search: SearchScreen()
case .Downloads: DownloadsScreen() case .Downloads: DownloadsScreen()
@@ -54,6 +59,10 @@ struct RootView: View {
case .Albums: AlbumsScreen() case .Albums: AlbumsScreen()
case .Songs: SongsScreen() case .Songs: SongsScreen()
case .Playlists: PlaylistsScreen() case .Playlists: PlaylistsScreen()
case .Genres: GenresScreen()
case .Downloads: DownloadsScreen()
case .TabBar: TabBarScreen()
case .GenrePage(let g): GenreScreen(genre: g)
case .Eq: EqScreen() case .Eq: EqScreen()
case .EqFilters(let id): EqFiltersScreen(profileId: id) case .EqFilters(let id): EqFiltersScreen(profileId: id)
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f) case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
@@ -642,3 +642,56 @@ struct DownloadsScreen: View {
} }
} }
} }
// ---- genres -----------------------------------------------------------------------------------
struct GenresScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
var body: some View {
Page(title: "Genres") {
Loaded(state: albums, retry: { reload += 1 }) { list in
let counts = Dictionary(grouping: list.compactMap(\.genre).filter { !$0.isEmpty }, by: { $0 }).mapValues(\.count)
LazyVStack(spacing: 0) {
ForEach(counts.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { g in
Button { ui.push(.GenrePage(g)) } label: {
HStack {
Text(g).foregroundStyle(p.label)
Spacer()
Text("\(counts[g] ?? 0)").foregroundStyle(p.label2)
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label2)
}
.padding(.horizontal, 16).padding(.vertical, 12)
}
.buttonStyle(.plain)
Divider().padding(.leading, 16)
}
}
}
}
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
}
}
}
struct GenreScreen: View {
@Environment(Ui.self) private var ui
let genre: String
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
var body: some View {
Page(title: genre) {
Loaded(state: albums, retry: { reload += 1 }) { list in
AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
}
}
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
}
}
}
@@ -172,7 +172,7 @@ struct NowPlayingScreen: View {
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
} }
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track)) ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, wide ? 24 : panel == .Art ? 34 : 18) .padding(.top, wide ? 24 : panel == .Art ? 34 : 30)
HStack { HStack {
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle) SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
Spacer() Spacer()
@@ -199,6 +199,7 @@ struct NowPlayingScreen: View {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) } PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
SpeedMenu() SpeedMenu()
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity) Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() } PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) } PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
} }
+117 -1
View File
@@ -153,6 +153,11 @@ struct SettingsScreen: View {
} header: { Header(text: "Backup") } footer: { } header: { Header(text: "Backup") } footer: {
Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.") Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.")
} }
Section {
DebugCells()
} header: { Header(text: "Debug") } footer: {
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
}
Section { Section {
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance) NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
} header: { Header(text: "Display") } footer: { } header: { Header(text: "Display") } footer: {
@@ -508,7 +513,7 @@ struct StorageScreen: View {
let usage = g.cache.usage let usage = g.cache.usage
List { List {
Section { Section {
Button { ui.selectTab(.Downloads) } label: { Button { ui.openDownloads() } label: {
HStack { HStack {
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes)) ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3) Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
@@ -598,6 +603,9 @@ struct AppearanceScreen: View {
.tint(Color(hex: 0x34C759)) .tint(Color(hex: 0x34C759))
.themedRow() .themedRow()
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") } } header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
Section {
NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow()
}
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.themedList() .themedList()
@@ -605,3 +613,111 @@ struct AppearanceScreen: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
} }
// ---- tab bar editor ---------------------------------------------------------------------------
struct TabBarScreen: View {
@Environment(\.palette) private var c
private var g: AppGraph { .shared }
var body: some View {
@Bindable var s = g.settings
let shown = Tab.layout(s.tabLayout)
let hidden = Tab.allCases.filter { !shown.contains($0) }
List {
Section {
ForEach(shown, id: \.self) { t in
HStack {
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
if t == .Settings { Spacer(); Text("Always on").font(.footnote).foregroundStyle(c.label2) }
}
.themedRow()
.deleteDisabled(t == .Settings)
}
.onMove { from, to in
var l = shown
l.move(fromOffsets: from, toOffset: to)
s.tabLayout = Tab.encode(l)
}
.onDelete { idx in
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
}
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
if !hidden.isEmpty {
Section {
ForEach(hidden, id: \.self) { t in
Button { s.tabLayout = Tab.encode(shown + [t]) } label: {
HStack {
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
Spacer()
Image(systemName: "plus.circle.fill").foregroundStyle(shown.count < Tab.maxVisible ? c.accent : c.label2)
}
}
.disabled(shown.count >= Tab.maxVisible)
.themedRow()
}
} header: { Header(text: "Available") }
}
Section {
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
}
}
.environment(\.editMode, .constant(.active))
.listStyle(.insetGrouped)
.themedList()
.navigationTitle("Tab Bar")
.navigationBarTitleDisplayMode(.inline)
}
}
// ---- debug ----------------------------------------------------------------------------------
private struct DebugCells: View {
@AppStorage(Log.verboseKey) private var verbose = false
@State private var logURL: URL?
@State private var viewing = false
@Environment(\.palette) private var p
var body: some View {
Toggle(isOn: $verbose) {
HStack(spacing: 14) {
SettingsIcon(symbol: "ladybug.fill", bg: 0xFF9500)
Text("Verbose Logging").foregroundStyle(p.label)
}
}
.tint(Color(hex: 0x34C759))
.themedRow()
Button { viewing = true } label: { Text("View Log").foregroundStyle(p.label) }.themedRow()
if let logURL {
ShareLink(item: logURL) { Text("Export Log").foregroundStyle(p.accent) }.themedRow()
} else {
Button { logURL = Log.shared.export() } label: { Text("Prepare Log for Export").foregroundStyle(p.accent) }.themedRow()
}
Button(role: .destructive) { Log.shared.clear(); logURL = nil } label: { Text("Clear Log") }.themedRow()
.sheet(isPresented: $viewing) { LogViewer() }
}
}
private struct LogViewer: View {
@State private var text = Log.shared.text()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
Text(text.isEmpty ? "Nothing logged yet." : text)
.font(.system(size: 11, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.textSelection(.enabled)
}
.defaultScrollAnchor(.bottom)
.navigationTitle("Log")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
ToolbarItem(placement: .primaryAction) { Button("Refresh") { text = Log.shared.text() } }
}
}
}
}