diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj index adbaa67..e45fcc9 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion.xcodeproj/project.pbxproj @@ -268,6 +268,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "cardboy-companion/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Cardboy; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -304,6 +305,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "cardboy-companion/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Cardboy; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift index 55a6ff8..9df10c2 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift @@ -98,6 +98,68 @@ private struct FileManagerTabView: View { @Binding var shareURL: URL? @Binding var errorWrapper: ErrorWrapper? + @State private var navigationPath: [String] = [] + + var body: some View { + ZStack { + NavigationStack(path: $navigationPath) { + DirectoryView( + path: "/", + navigationPath: $navigationPath, + shareURL: $shareURL, + errorWrapper: $errorWrapper + ) + .navigationDestination(for: String.self) { destination in + DirectoryView( + path: destination, + navigationPath: $navigationPath, + shareURL: $shareURL, + errorWrapper: $errorWrapper + ) + } + } + + if let operation = manager.activeFileOperation { + FileOperationHUD(operation: operation) + } + + if manager.connectionState != .ready { + ConnectionOverlay( + state: manager.connectionState, + statusMessage: manager.statusMessage, + retryAction: manager.forceRescan + ) + } + } + .onAppear { + if manager.currentDirectory != "/" { + manager.changeDirectory(to: "/") + } + navigationPath = stackForPath(manager.currentDirectory) + } + .onChange(of: manager.currentDirectory) { newValue in + let desired = stackForPath(newValue) + if desired != navigationPath { + navigationPath = desired + } + } + .onChange(of: navigationPath) { newValue in + let target = newValue.last ?? "/" + if target != manager.currentDirectory { + manager.changeDirectory(to: target) + } + } + } +} + +private struct DirectoryView: View { + let path: String + @Binding var navigationPath: [String] + @Binding var shareURL: URL? + @Binding var errorWrapper: ErrorWrapper? + + @EnvironmentObject private var manager: TimeSyncManager + @State private var showingImporter = false @State private var showingNewFolderSheet = false @State private var showingRenameSheet = false @@ -105,8 +167,18 @@ private struct FileManagerTabView: View { @State private var renameText = "" @State private var renameTarget: TimeSyncManager.RemoteFileEntry? - private var pathComponents: [String] { - manager.currentDirectory.split(separator: "/").map(String.init) + private var pathSegments: [(name: String, fullPath: String)] { + var segments: [(String, String)] = [] + var current = "" + for component in path.split(separator: "/").map(String.init) { + current += "/" + component + segments.append((component, current)) + } + return segments + } + + private var displayTitle: String { + pathSegments.last?.name ?? "Files" } var body: some View { @@ -117,18 +189,16 @@ private struct FileManagerTabView: View { .font(.headline) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 4) { - Button(action: { manager.changeDirectory(to: "/") }) { + Button(action: { navigationPath = [] }) { Text("/") } .buttonStyle(.bordered) - ForEach(pathComponents.indices, id: \.self) { index in - let component = pathComponents[index] + ForEach(Array(pathSegments.enumerated()), id: \.element.fullPath) { index, segment in Button(action: { - let path = "/" + pathComponents.prefix(index + 1).joined(separator: "/") - manager.changeDirectory(to: path) + navigationPath = Array(pathSegments.prefix(index + 1).map(\.fullPath)) }) { - Text(component) + Text(segment.name) } .buttonStyle(.bordered) } @@ -139,27 +209,13 @@ private struct FileManagerTabView: View { List { ForEach(manager.directoryEntries) { entry in - FileRow(entry: entry) - .contentShape(Rectangle()) - .onTapGesture { - if entry.isDirectory { - manager.enter(directory: entry) - } + if entry.isDirectory { + NavigationLink(value: entry.path) { + FileRow(entry: entry) } .contextMenu { - if entry.isDirectory { - Button("Open", action: { manager.enter(directory: entry) }) - } else { - Button("Download") { - manager.download(entry: entry) { result in - switch result { - case .success(let url): - shareURL = url - case .failure(let error): - errorWrapper = ErrorWrapper(message: error.localizedDescription) - } - } - } + Button("Open") { + navigationPath = stackForPath(entry.path) } Button("Rename") { @@ -174,32 +230,56 @@ private struct FileManagerTabView: View { Text("Delete") } } + } else { + FileRow(entry: entry) + .contextMenu { + Button("Download") { + manager.download(entry: entry) { result in + switch result { + case .success(let url): + shareURL = url + case .failure(let error): + errorWrapper = ErrorWrapper(message: error.localizedDescription) + } + } + } + + Button("Rename") { + renameTarget = entry + renameText = entry.name + showingRenameSheet = true + } + + Button(role: .destructive) { + manager.delete(entry: entry) + } label: { + Text("Delete") + } + } + } } } .listStyle(.plain) - HStack { - Button { - showingImporter = true - } label: { - Label("Upload", systemImage: "square.and.arrow.up") - } - .buttonStyle(.bordered) + HStack(spacing: 12) { + Menu { + Button { + showingImporter = true + } label: { + Label("Upload File", systemImage: "square.and.arrow.up") + } - Button { - showingNewFolderSheet = true - newFolderName = "" + Button { + showingNewFolderSheet = true + newFolderName = "" + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - .buttonStyle(.bordered) - - Button { - manager.navigateUp() - } label: { - Label("Up", systemImage: "arrow.up") + Label("Actions", systemImage: "ellipsis.circle") } .buttonStyle(.bordered) + .controlSize(.large) Spacer() @@ -208,21 +288,18 @@ private struct FileManagerTabView: View { } label: { Label("Refresh", systemImage: "arrow.clockwise") } - .buttonStyle(.bordered) + .buttonStyle(.borderedProminent) + .controlSize(.large) } .padding([.horizontal, .bottom]) } - if let operation = manager.activeFileOperation { - FileOperationHUD(operation: operation) - } - - if manager.connectionState != .ready { - ConnectionOverlay( - state: manager.connectionState, - statusMessage: manager.statusMessage, - retryAction: manager.forceRescan - ) + } + .navigationTitle(displayTitle) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + if manager.currentDirectory != path { + manager.changeDirectory(to: path) } } .fileImporter( @@ -297,6 +374,17 @@ private struct FileManagerTabView: View { } } +private func stackForPath(_ path: String) -> [String] { + guard path != "/" else { return [] } + var stack: [String] = [] + var current = "" + for component in path.split(separator: "/").map(String.init) { + current += "/" + component + stack.append(current) + } + return stack +} + private struct FileRow: View { let entry: TimeSyncManager.RemoteFileEntry diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift index a5b3c06..21adfa2 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift @@ -191,9 +191,11 @@ final class TimeSyncManager: NSObject, ObservableObject { payload.append(isDst) payload.append(UInt8(0)) // Reserved byte - connectionState = .ready - statusMessage = "Sending current time…" peripheral.writeValue(payload, for: characteristic, type: .withResponse) + lastSyncDate = now + connectionState = .ready + let timeString = DateFormatter.localizedString(from: now, dateStyle: .none, timeStyle: .medium) + statusMessage = "Time synced at \(timeString)." } // MARK: - File operations exposed to UI