diff --git a/Flipper/Flipper.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Flipper/Flipper.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 55895e656..cc9c4c4ed 100644 --- a/Flipper/Flipper.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Flipper/Flipper.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Countly/countly-sdk-ios.git", "state" : { - "revision" : "e12be8153743a17c1389f80f9f7401c9f7f7de61", - "version" : "24.4.0" + "revision" : "a19d604ae32ccdeafad0c30e60235e221af09e80", + "version" : "24.7.9" } }, { @@ -24,7 +24,7 @@ "location" : "https://github.com/tonyfreeman/firebase-ios-sdk", "state" : { "branch" : "master", - "revision" : "fccc5e45f145baf6c9ac73f9579180efd9334f8d" + "revision" : "f22ed7e37b87328cdd05a08b5a8e6b74a840ad10" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", - "version" : "9.4.0" + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "26c898aed8bed13b8a63057ee26500abbbcb8d55", - "version" : "7.13.1" + "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", + "version" : "8.0.2" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/NetworkImage", "state" : { - "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", - "version" : "6.0.0" + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", - "version" : "1.1.0" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { @@ -104,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", - "version" : "1.5.4" + "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", + "version" : "1.6.2" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", - "version" : "1.26.0" + "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", + "version" : "1.28.2" } }, { diff --git a/Flipper/Packages/Core/Sources/FileManager/ExtendedElement.swift b/Flipper/Packages/Core/Sources/FileManager/ExtendedElement.swift new file mode 100644 index 000000000..bacda3312 --- /dev/null +++ b/Flipper/Packages/Core/Sources/FileManager/ExtendedElement.swift @@ -0,0 +1,19 @@ +import Peripheral + +public struct ExtendedElement: Equatable, Identifiable { + public var id: String { path.string } + + public let path: Path + public let type: Element + + public var name: String { + type.name + } +} + +extension ExtendedElement { + init(element: Element, relativeTo path: Path) { + self.path = path.appending(element.name) + self.type = element + } +} diff --git a/Flipper/Packages/Core/Sources/Model/RemoteFileManager.swift b/Flipper/Packages/Core/Sources/FileManager/RemoteFileManager.swift similarity index 94% rename from Flipper/Packages/Core/Sources/Model/RemoteFileManager.swift rename to Flipper/Packages/Core/Sources/FileManager/RemoteFileManager.swift index e15b8b1b5..586771c5e 100644 --- a/Flipper/Packages/Core/Sources/Model/RemoteFileManager.swift +++ b/Flipper/Packages/Core/Sources/FileManager/RemoteFileManager.swift @@ -23,9 +23,11 @@ public class RemoteFileManager: ObservableObject { // MARK: Directory - public func list(at path: Path) async throws -> [Element] { + public func list(at path: Path) async throws -> [ExtendedElement] { do { - return try await storage.list(at: path) + return try await storage + .list(at: path) + .map { .init(element: $0, relativeTo: path) } } catch { logger.error("list directory: \(error)") throw Error.unknown(.init(describing: error)) diff --git a/Flipper/Packages/Core/Sources/Storage/Patform/UserDefaultsStorage.swift b/Flipper/Packages/Core/Sources/Storage/Patform/UserDefaultsStorage.swift index 2b6e6f7d1..11ae554b6 100644 --- a/Flipper/Packages/Core/Sources/Storage/Patform/UserDefaultsStorage.swift +++ b/Flipper/Packages/Core/Sources/Storage/Patform/UserDefaultsStorage.swift @@ -109,5 +109,7 @@ public extension UserDefaults { case appsSortOrder = "appsSortOrder" case todayWidgetUpdated = "todayWidgetUpdated" + + case fileManagerSettings = "fileManagerSettings" } } diff --git a/Flipper/Packages/Peripheral/Sources/RPC/Model/Storage.swift b/Flipper/Packages/Peripheral/Sources/RPC/Model/Storage.swift index 27652cb24..a6bee2635 100644 --- a/Flipper/Packages/Peripheral/Sources/RPC/Model/Storage.swift +++ b/Flipper/Packages/Peripheral/Sources/RPC/Model/Storage.swift @@ -5,6 +5,11 @@ public struct StorageSpace: Equatable { public let total: Int public var used: Int { total - free } + + public init(free: Int, total: Int) { + self.free = free + self.total = total + } } public enum Element: Equatable { diff --git a/Flipper/Shared/Assets.xcassets/FileManager/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/CreateFile.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/CreateFile.imageset/Contents.json new file mode 100644 index 000000000..a8e96ec94 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/CreateFile.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "CreateFile.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/CreateFile.imageset/CreateFile.svg b/Flipper/Shared/Assets.xcassets/FileManager/CreateFile.imageset/CreateFile.svg new file mode 100644 index 000000000..437831ba8 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/CreateFile.imageset/CreateFile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/CreateFolder.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/CreateFolder.imageset/Contents.json new file mode 100644 index 000000000..2be9a19cd --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/CreateFolder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "CreateFolder.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/CreateFolder.imageset/CreateFolder.svg b/Flipper/Shared/Assets.xcassets/FileManager/CreateFolder.imageset/CreateFolder.svg new file mode 100644 index 000000000..5d43e160a --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/CreateFolder.imageset/CreateFolder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/File.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/File.imageset/Contents.json new file mode 100644 index 000000000..671bd1307 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/File.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "File.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/File.imageset/File.svg b/Flipper/Shared/Assets.xcassets/FileManager/File.imageset/File.svg new file mode 100644 index 000000000..b9bde621d --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/File.imageset/File.svg @@ -0,0 +1,3 @@ + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/FileManagerIcon.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/FileManagerIcon.imageset/Contents.json new file mode 100644 index 000000000..4a4ac044d --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/FileManagerIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "FileManagerIcon.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/FileManagerIcon.imageset/FileManagerIcon.svg b/Flipper/Shared/Assets.xcassets/FileManager/FileManagerIcon.imageset/FileManagerIcon.svg new file mode 100644 index 000000000..a1f32dc96 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/FileManagerIcon.imageset/FileManagerIcon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/Folder.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/Folder.imageset/Contents.json new file mode 100644 index 000000000..b154a4f36 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/Folder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Folder.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/Folder.imageset/Folder.svg b/Flipper/Shared/Assets.xcassets/FileManager/Folder.imageset/Folder.svg new file mode 100644 index 000000000..cae061b59 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/Folder.imageset/Folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/Grid.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/Grid.imageset/Contents.json new file mode 100644 index 000000000..917df9410 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/Grid.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Grid.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/Grid.imageset/Grid.svg b/Flipper/Shared/Assets.xcassets/FileManager/Grid.imageset/Grid.svg new file mode 100644 index 000000000..ff5fb7b75 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/Grid.imageset/Grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/List.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/List.imageset/Contents.json new file mode 100644 index 000000000..d75f53cb9 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/List.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "List.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/List.imageset/List.svg b/Flipper/Shared/Assets.xcassets/FileManager/List.imageset/List.svg new file mode 100644 index 000000000..7c5a59fc7 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/List.imageset/List.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/SDCard.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/SDCard.imageset/Contents.json new file mode 100644 index 000000000..181d25a0f --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/SDCard.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "SDCard.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/SDCard.imageset/SDCard.svg b/Flipper/Shared/Assets.xcassets/FileManager/SDCard.imageset/SDCard.svg new file mode 100644 index 000000000..9b86dda4e --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/SDCard.imageset/SDCard.svg @@ -0,0 +1,3 @@ + + + diff --git a/Flipper/Shared/Assets.xcassets/FileManager/SDCardDummy.imageset/Contents.json b/Flipper/Shared/Assets.xcassets/FileManager/SDCardDummy.imageset/Contents.json new file mode 100644 index 000000000..a7854f7f9 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/SDCardDummy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "SDCardDummy.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Flipper/Shared/Assets.xcassets/FileManager/SDCardDummy.imageset/SDCardDummy.svg b/Flipper/Shared/Assets.xcassets/FileManager/SDCardDummy.imageset/SDCardDummy.svg new file mode 100644 index 000000000..04514fcf2 --- /dev/null +++ b/Flipper/Shared/Assets.xcassets/FileManager/SDCardDummy.imageset/SDCardDummy.svg @@ -0,0 +1,3 @@ + + + diff --git a/Flipper/iOS/UI/Archive/ArchiveView.swift b/Flipper/iOS/UI/Archive/ArchiveView.swift index 8dfdedadd..fe5f5b55b 100644 --- a/Flipper/iOS/UI/Archive/ArchiveView.swift +++ b/Flipper/iOS/UI/Archive/ArchiveView.swift @@ -26,6 +26,7 @@ struct ArchiveView: View { case importing(URL) case category(ArchiveItem.Kind) case categoryDeleted + case fileManager } var canPullToRefresh: Bool { @@ -33,6 +34,11 @@ struct ArchiveView: View { device.status == .synchronized } + var isFileManagerAvailable: Bool { + device.status == .connected || + device.status == .synchronized + } + var items: [ArchiveItem] { archive.items } @@ -70,6 +76,13 @@ struct ArchiveView: View { ) .padding(14) + NavigationLink(value: Destination.fileManager) { + FileManagerSection() + .padding(.horizontal, 14) + .padding(.bottom, 14) + } + .disabled(!isFileManagerAvailable) + if !favoriteItems.isEmpty { FavoritesSection(items: favoriteItems) .padding(.horizontal, 14) @@ -134,6 +147,7 @@ struct ArchiveView: View { case .importing(let url): ImportView(url: url) case .category(let kind): CategoryView(kind: kind) case .categoryDeleted: CategoryDeletedView() + case .fileManager: FileManagerView() } } } diff --git a/Flipper/iOS/UI/Archive/Components/FileManager.swift b/Flipper/iOS/UI/Archive/Components/FileManager.swift new file mode 100644 index 000000000..cb6b0e7ab --- /dev/null +++ b/Flipper/iOS/UI/Archive/Components/FileManager.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct FileManagerSection: View { + @Environment(\.isEnabled) var isEnabled + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image("FileManagerIcon") + .resizable() + .renderingMode(.template) + .frame(width: 30, height: 30) + .foregroundColor(.primary) + + Text("File Manager") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.primary) + + Spacer() + + Image("ChevronRight") + .resizable() + .frame(width: 14, height: 14) + } + + Text("Manage files and assets on your Flipper Zero") + .font(.system(size: 14, weight: .medium)) + .multilineTextAlignment(.leading) + .foregroundColor(.black30) + } + .padding(12) + .opacity(isEnabled ? 1 : 0.4) + .background(Color.groupedBackground) + .cornerRadius(12) + } +} diff --git a/Flipper/iOS/UI/FileManager/Components/ElementRow.swift b/Flipper/iOS/UI/FileManager/Components/ElementRow.swift new file mode 100644 index 000000000..9cfeed0ac --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/ElementRow.swift @@ -0,0 +1,274 @@ +import Core + +import SwiftUI + +extension FileManagerView.FileManagerListing { + struct ElementRowGrid: View { + let element: ExtendedElement + + let onSelect: () -> Void + let onTap: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Icon(for: element) + Spacer() + Action(onTap: onSelect) + } + Title(for: element) + } + .padding(12) + .background(Color.groupedBackground) + .cornerRadius(12) + .onTapGesture { onTap() } + } + } + + struct ElementRowList: View { + @State private var offset: CGFloat = 0 + + let element: ExtendedElement + + let onSelect: () -> Void + let onDelete: () -> Void + let onTap: () -> Void + + var body: some View { + HStack(spacing: 12) { + Icon(for: element) + Title(for: element) + Spacer() + Action(onTap: onSelect) + .opacity(offset == 0 ? 1 : 0) + .animation(nil, value: offset) + } + .padding(12) + .background(Color.groupedBackground) + .modifier( + SwipeToDeleteModifier( + element: element, + offset: $offset, + onDelete: onDelete, + onTap: onTap + ) + ) + } + } +} + +fileprivate extension FileManagerView.FileManagerListing { + struct Icon: View { + let element: ExtendedElement + + private var image: Image { + switch element.type { + case .directory: + return .init("Folder") + case .file: + do { + let item = try ArchiveItem.Kind(filename: element.name) + return item.icon + } catch { + return Image("File") + } + } + } + + init(for element: ExtendedElement) { + self.element = element + } + + var body: some View { + image + .resizable() + .renderingMode(.template) + .frame(width: 24, height: 24) + .foregroundColor(.primary) + } + } + + struct Title: View { + let element: ExtendedElement + + init(for element: ExtendedElement) { + self.element = element + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(element.name) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + + if case let .file(file) = element.type { + Text(file.size.hr) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.black30) + } + } + .frame(height: 32) + } + } + + struct Action: View { + let onTap: () -> Void + + var body: some View { + Image(systemName: "ellipsis") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .foregroundColor(.black30) + .frame(width: 20) + .padding([.vertical, .leading], 12) + .contentShape(Rectangle()) + .onTapGesture { onTap() } + } + } +} + +fileprivate extension FileManagerView.FileManagerListing { + final class CurrentElementHolder: ObservableObject { + @MainActor static let shared = CurrentElementHolder() + + @Published var element: ExtendedElement? + } + + struct SwipeToDeleteModifier: ViewModifier { + @ObservedObject private var currentHolder = CurrentElementHolder.shared + + let element: ExtendedElement + @Binding var offset: CGFloat + + let onDelete: () -> Void + let onTap: () -> Void + + private var iconSize: Double { 24 } + private var iconPadding: Double { 16 } + + private var deleteThreshold: CGFloat { -(iconSize + iconPadding * 2) } + private var fullDeleteThreshold: CGFloat { -160 } + + private var delay: Double { 0.5 } + private var animation: Animation { .easeOut(duration: delay) } + + private var maxRadius: CGFloat { 12 } + private var radius: CGFloat { + let progress = min(1, abs(offset) / abs(deleteThreshold)) + return maxRadius * (1 - progress) + } + + func body(content: Content) -> some View { + ZStack(alignment: .trailing) { + Image("Delete") + .resizable() + .renderingMode(.template) + .foregroundColor(.red) + .frame(width: iconSize, height: iconSize) + .padding(16) + .contentShape(Rectangle()) + .onTapGesture { + deleteAction() + } + + content + .clipShape( + .rect( + topLeadingRadius: maxRadius, + bottomLeadingRadius: maxRadius, + bottomTrailingRadius: radius, + topTrailingRadius: radius + ) + ) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.red.opacity(0.1)) + .offset(x: -offset) + ) + .offset(x: offset) + .simultaneousGesture( + DragGesture( + minimumDistance: 10, + coordinateSpace: .local + ) + .onChanged { value in + if currentHolder.element != element { + currentHolder.element = element + } + + let translation = value.translation.width + + if value.isHorizontal { + if translation <= 0 { + withAnimation(.interactiveSpring()) { + offset = translation + } + } + } + } + .onEnded { value in + let translation = value.translation.width + + if value.isHorizontal { + if translation <= fullDeleteThreshold { + deleteAction() + } else if translation <= deleteThreshold { + withAnimation(animation) { + offset = deleteThreshold + } + } else { + closeAction() + } + } else { + withAnimation(.interactiveSpring()) { + offset = 0 + } + } + } + ) + .onTapGesture { + if offset != 0 { + closeAction() + } else { + onTap() + } + } + .onChange(of: currentHolder.element) { new in + if new != element && offset != 0 { + closeAction() + } + } + } + } + + private func closeAction() { + withAnimation(animation) { + offset = 0 + } + } + + private func deleteAction() { + Task { @MainActor in + withAnimation(animation) { + offset = -UIScreen.main.bounds.width + } + try await Task.sleep(seconds: delay) + onDelete() + + withAnimation(animation) { + offset = 0 + } + } + } + } +} + +fileprivate extension DragGesture.Value { + var isHorizontal: Bool { + let horizontalAmount = abs(translation.width) + let verticalAmount = abs(translation.height) + return horizontalAmount > verticalAmount + } +} diff --git a/Flipper/iOS/UI/FileManager/Components/EmptyFolder.swift b/Flipper/iOS/UI/FileManager/Components/EmptyFolder.swift new file mode 100644 index 000000000..1f82071f0 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/EmptyFolder.swift @@ -0,0 +1,34 @@ +import SwiftUI + +extension FileManagerView.FileManagerListing { + struct EmptyFolder: View { + let onUpload: () -> Void + + var body: some View { + VStack(alignment: .center, spacing: 24) { + Spacer() + + Text("No Files Yet") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.primary) + + Image("ReportFailed") + .renderingMode(.template) + .foregroundColor(.primary) + + Text("Upload Files") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.a2) + .onTapGesture { + onUpload() + } + + Spacer() + } + } + } +} + +#Preview { + FileManagerView.FileManagerListing.EmptyFolder(onUpload: {}) +} diff --git a/Flipper/iOS/UI/FileManager/Components/FileEditorOptions.swift b/Flipper/iOS/UI/FileManager/Components/FileEditorOptions.swift new file mode 100644 index 000000000..d2b9b3026 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/FileEditorOptions.swift @@ -0,0 +1,45 @@ +import SwiftUI + +extension FileManagerView.FileManagerEditor { + struct FileEditorOptions: View { + @Binding var isPresented: Bool + + let save: () -> Void + let saveAs: () -> Void + + var body: some View { + HStack { + Spacer() + Card { + VStack(alignment: .leading, spacing: 0) { + Option(text: "Save") { + isPresented = false + save() + } + } + } + .frame(width: 150) + } + .padding(.horizontal, 14) + .offset(y: 40) + } + } +} + +fileprivate extension FileManagerView.FileManagerEditor.FileEditorOptions { + struct Option: View { + let text: String + let onTap: () -> Void + + var body: some View { + HStack { + Text(text) + .font(.system(size: 16, weight: .medium)) + .padding(.horizontal, 16) + .padding(.vertical, 12) + Spacer() + } + .onTapGesture { onTap() } + } + } +} diff --git a/Flipper/iOS/UI/FileManager/Components/FileListingOptions.swift b/Flipper/iOS/UI/FileManager/Components/FileListingOptions.swift new file mode 100644 index 000000000..f12ca053a --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/FileListingOptions.swift @@ -0,0 +1,116 @@ +import SwiftUI + +extension FileManagerView.FileManagerListing { + struct FileListingOptions: View { + @Binding var isPresented: Bool + @Binding var settings: FileManagerSettings + + let createFolder: () -> Void + let createFile: () -> Void + let upload: () -> Void + + var body: some View { + HStack { + Spacer() + Card { + VStack(alignment: .leading, spacing: 0) { + Option(title: "Create Folder", image: "CreateFolder") { + isPresented = false + createFolder() + } + + Option(title: "Create File", image: "CreateFile") { + isPresented = false + createFile() + } + + Option(title: "Upload", image: "Share") { + isPresented = false + upload() + } + + Divider() + + Option(title: "List", image: "List") { + isPresented = false + settings.displayType = .list + } + + Option(title: "Grid", image: "Grid") { + isPresented = false + settings.displayType = .grid + } + + Divider() + + ShowHiddenFilesOption( + isPresented: $isPresented, + settings: $settings + ) + } + } + .frame(width: 200) + } + .padding(.horizontal, 14) + .offset(y: 40) + } + } +} + +fileprivate extension FileManagerView.FileManagerListing.FileListingOptions { + struct Option: View { + let title: String + let image: String + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 8) { + Image(image) + .resizable() + .renderingMode(.template) + .frame(width: 24, height: 24) + .foregroundColor(.primary) + Text(title) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + Spacer() + } + } + .padding(12) + } + } + + struct ShowHiddenFilesOption: View { + @Binding var isPresented: Bool + @Binding var settings: FileManagerSettings + + var body: some View { + Button(action: { + settings.isHiddenFilesShow.toggle() + isPresented = false + }, label: { + HStack(spacing: 8) { + ZStack { + Circle() + .stroke(Color.black30, lineWidth: 2) + + if settings.isHiddenFilesShow { + Circle() + .fill(Color.a1) + .padding(4) + } + } + .frame(width: 20, height: 20) + .padding(2) + + Text("Show Hidden Files") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + Spacer() + } + }) + .padding(12) + } + } +} diff --git a/Flipper/iOS/UI/FileManager/Components/FileManagerElements.swift b/Flipper/iOS/UI/FileManager/Components/FileManagerElements.swift new file mode 100644 index 000000000..d05b89ff4 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/FileManagerElements.swift @@ -0,0 +1,42 @@ +import Core + +import SwiftUI + +extension FileManagerView.FileManagerListing { + struct FileManagerElements: View { + let elements: [ExtendedElement] + let displayType: FileManagerSettings.DisplayType + + let onTap: (ExtendedElement) -> Void + let onDelete: (ExtendedElement) -> Void + let onSelect: (ExtendedElement) -> Void + + private let columns = [GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + switch displayType { + case .list: + LazyVStack(spacing: 12) { + ForEach(elements) { element in + ElementRowList( + element: element, + onSelect: { onSelect(element) }, + onDelete: { onDelete(element) }, + onTap: { onTap(element) } + ) + } + } + case .grid: + LazyVGrid(columns: columns, spacing: 12) { + ForEach(elements) { element in + ElementRowGrid( + element: element, + onSelect: { onSelect(element) }, + onTap: { onTap(element) } + ) + } + } + } + } + } +} diff --git a/Flipper/iOS/UI/FileManager/Components/NavigationPathView.swift b/Flipper/iOS/UI/FileManager/Components/NavigationPathView.swift new file mode 100644 index 000000000..6ec567172 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/NavigationPathView.swift @@ -0,0 +1,122 @@ +import SwiftUI +import Peripheral + +extension FileManagerView { + struct NavigationPathView: View { + @Environment(\.path) private var navigationPath + + let path: Peripheral.Path + + private var components: [String] { + path + .string + .split(separator: "/") + .map(String.init) + .filter { $0 != "ext" } + } + + var gradient: LinearGradient { + LinearGradient( + gradient: Gradient( + stops: [ + Gradient.Stop(color: .background, location: 0.2), + Gradient.Stop(color: .clear, location: 1) + ] + ), + startPoint: .leading, + endPoint: .trailing + ) + } + + var body: some View { + HStack(spacing: 0) { + Image("SDCardDummy") + .resizable() + .renderingMode(.template) + .frame(width: 20, height: 24) + .foregroundColor(.primary) + .onTapGesture { navigate(to: 0) } + + ZStack(alignment: .leading) { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + Spacer() + .frame(width: 0, height: 0) + + ForEach( + components.indices, + id: \.self + ) { index in + Element( + component: components[index], + index: index, + onTap: navigate + ) + .id(index) + } + } + } + .onAppear { + if let lastIndex = components.indices.last { + proxy.scrollTo(lastIndex, anchor: .trailing) + } + } + } + + Rectangle() + .fill(gradient) + .frame(width: 8, height: 24) + } + } + .padding(4) + } + + private func navigate(to index: Int) { + let stack = navigationPath.wrappedValue.count + navigationPath.wrappedValue.removeLast(stack - index - 1) + } + } +} + +fileprivate extension FileManagerView.NavigationPathView { + struct Element: View { + let component: String + let index: Int + let onTap: (Int) -> Void + + var body: some View { + HStack(spacing: 8) { + Text("/") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black30) + + Text(component) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.primary) + .onTapGesture { onTap(index + 1) } + } + } + } +} + +#Preview { + VStack { + FileManagerView.NavigationPathView( + path: Peripheral.Path(string: "/ext/apps") + ) + + FileManagerView.NavigationPathView( + path: Peripheral.Path(string: "/ext/Downloads/2021/08/17") + ) + + FileManagerView.NavigationPathView( + path: Peripheral.Path( + string: "/ext/Downloads/2021/08/17/dummy/test/file" + ) + ) + } + .environment(\.path, .constant(NavigationPath())) + .padding(12) + .background(Color.background) +} diff --git a/Flipper/iOS/UI/FileManager/Components/SDCardInfo.swift b/Flipper/iOS/UI/FileManager/Components/SDCardInfo.swift new file mode 100644 index 000000000..7a48ab305 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/SDCardInfo.swift @@ -0,0 +1,105 @@ +import SwiftUI +import Peripheral + +extension FileManagerView { + struct SDCardInfo: View { + let storage: StorageSpace? + + var body: some View { + HStack(spacing: 32) { + VStack(alignment: .leading, spacing: 12) { + Text("SD Card") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.primary) + + ProgressBar(storage: storage) + + HStack { + Info(title: "Used", space: storage?.used) + Spacer() + Info(title: "Total", space: storage?.total) + } + .padding(.horizontal, 12) + } + + Image("SDCard") + .resizable() + .renderingMode(.template) + .frame(width: 84, height: 84) + .foregroundColor(.primary) + } + .padding(12) + .background(Color.groupedBackground) + .cornerRadius(12) + } + } +} + +fileprivate extension FileManagerView.SDCardInfo { + struct ProgressBar: View { + let storage: StorageSpace? + + var body: some View { + Group { + if let storage { + GeometryReader { geometry in + HStack(spacing: 0) { + Rectangle() + .fill(Color.orange) + .frame( + width: geometry.size.width + * storage.usedRatio + ) + + Rectangle() + .fill(Color.orange.opacity(0.3)) + } + } + } else { + AnimatedPlaceholder() + } + } + .frame(height: 8) + .cornerRadius(4) + } + } + + struct Info: View { + let title: String + let space: Int? + + var body: some View { + VStack { + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.black30) + + if let space { + Text(space.hr) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + } else { + AnimatedPlaceholder() + .frame(width: 50, height: 10) + } + } + } + } +} + +fileprivate extension StorageSpace { + var usedRatio: CGFloat { + guard total > 0 else { return 0 } + return CGFloat(used) / CGFloat(total) + } +} + +#Preview { + VStack { + FileManagerView.SDCardInfo(storage: nil) + + FileManagerView.SDCardInfo(storage: .init(free: 10, total: 40)) + } + .padding(12) + .background(Color.background) +} diff --git a/Flipper/iOS/UI/FileManager/Components/SaveChangesContentAlert.swift b/Flipper/iOS/UI/FileManager/Components/SaveChangesContentAlert.swift new file mode 100644 index 000000000..fe79953c2 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/SaveChangesContentAlert.swift @@ -0,0 +1,49 @@ +import SwiftUI + +extension FileManagerView.FileManagerEditor { + struct SaveChangesContentAlert: View { + @Binding var isPresented: Bool + let save: () -> Void + let saveAs: () -> Void + let dontSave: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 4) { + Text("Save Changes?") + .font(.system(size: 14, weight: .bold)) + .padding(.top, 5) + + Text("All unsaved changes will be lost") + .font(.system(size: 14, weight: .medium)) + .multilineTextAlignment(.center) + .foregroundColor(.black40) + .padding(.horizontal, 12) + } + + VStack(spacing: 14) { + Divider() + Button { + isPresented = false + save() + } label: { + Text("Save") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.a2) + } + + Divider() + Button { + isPresented = false + dontSave() + } label: { + Text("Don't save") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.primary) + } + } + } + } + } + +} diff --git a/Flipper/iOS/UI/FileManager/Components/SelectedElementSheet.swift b/Flipper/iOS/UI/FileManager/Components/SelectedElementSheet.swift new file mode 100644 index 000000000..a29f1b672 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/Components/SelectedElementSheet.swift @@ -0,0 +1,94 @@ +import Core + +import SwiftUI + +extension FileManagerView.FileManagerListing { + struct SelectedElementSheet: View { + @Environment(\.colorScheme) var colorScheme + + let element: ExtendedElement + + let onExport: (ExtendedElement) -> Void + let onDelete: (ExtendedElement) -> Void + + private var backgroundColor: Color { + colorScheme == .light ? .white : .black88 + } + + private var type: String { + switch element.type { + case .directory: "Folder" + case .file: "File" + } + } + + private var isDirectory: Bool { + return if case .directory = element.type { + true + } else { + false + } + } + + var body: some View { + VStack(spacing: 12) { + VStack(spacing: 2) { + Text(type) + .font(.system(size: 14, weight: .medium)) + + Text(element.name) + .font(.system(size: 14, weight: .bold)) + } + + Option( + image: "Share", + title: "Export" + ) { + onExport(element) + } + .disabled(isDirectory) + .foregroundColor(.primary) + + Option( + image: "Delete", + title: "Delete" + ) { + onDelete(element) + } + .foregroundColor(.red) + } + .padding(.horizontal, 14) + .background(backgroundColor) + .presentationDragIndicator(.visible) + .presentationDetents([.height(200)]) + .pickerStyle(.segmented) + } + } +} + +fileprivate extension FileManagerView.FileManagerListing.SelectedElementSheet { + struct Option: View { + @Environment(\.isEnabled) private var isEnabled + + let image: String + let title: String + let action: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(image) + .resizable() + .renderingMode(.template) + .frame(width: 24, height: 24) + + Text(title) + .font(.system(size: 14, weight: .medium)) + + Spacer() + } + .opacity(isEnabled ? 1 : 0.4) + .padding(12) + .onTapGesture { action() } + } + } +} diff --git a/Flipper/iOS/UI/FileManager/FileManagerEditor.swift b/Flipper/iOS/UI/FileManager/FileManagerEditor.swift new file mode 100644 index 000000000..bb1fcca6b --- /dev/null +++ b/Flipper/iOS/UI/FileManager/FileManagerEditor.swift @@ -0,0 +1,131 @@ +import Core +import Peripheral + +import SwiftUI + +extension FileManagerView { + struct FileManagerEditor: View { + let path: Peripheral.Path + + @EnvironmentObject var fileManager: RemoteFileManager + + @Environment(\.dismiss) var dismiss + @Environment(\.popups) var popups + + @State private var current: String = "" + @State private var backup: String = "" + + @State private var error: String? + @State private var isLoading = false + + @State private var showSaveChanges = false + @State private var showOptions = false + + @FocusState private var textFieldFocus: Bool + + var body: some View { + VStack { + if let error = error { + Text(error) + } else if isLoading { + ProgressView() + } else { + TextEditor(text: $current) + .focused($textFieldFocus) + .font(.system(size: 14, weight: .medium)) + .hideScrollBackground() + } + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.background) + .navigationBarBackground(Color.a1) + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + LeadingToolbarItems { + BackButton { + textFieldFocus = false + back() + } + } + PrincipalToolbarItems(alignment: .leading) { + Title(path.lastComponent ?? "") + } + TrailingToolbarItems { + SaveButton { + textFieldFocus = false + save() + } + .disabled(isLoading || error != nil) + } + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Text("Done") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.a1) + .onTapGesture { textFieldFocus = false } + } + } + } + .alert(isPresented: $showSaveChanges) { + SaveChangesContentAlert( + isPresented: $showSaveChanges, + save: save, + saveAs: saveAs, + dontSave: dontSave + ) + } + .popup(isPresented: $showOptions) { + FileEditorOptions( + isPresented: $showOptions, + save: save, + saveAs: saveAs + ) + } + .task { await load() } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + + do { + current = try await fileManager.readFile(at: path) + backup = current + } catch { + self.error = String(describing: error) + } + } + + private func back() { + if current != backup { + showSaveChanges = true + } else { + dismiss() + } + } + + private func save() { + Task { + isLoading = true + defer { isLoading = false } + + do { + try await fileManager.writeFile(current, at: path) + dismiss() + } catch { + self.error = String(describing: error) + } + } + } + + // TODO + private func saveAs() {} + + private func dontSave() { + dismiss() + } + } +} diff --git a/Flipper/iOS/UI/FileManager/FileManagerListing.swift b/Flipper/iOS/UI/FileManager/FileManagerListing.swift new file mode 100644 index 000000000..ed1bda50c --- /dev/null +++ b/Flipper/iOS/UI/FileManager/FileManagerListing.swift @@ -0,0 +1,313 @@ +import Core +import UniformTypeIdentifiers +import Peripheral + +import SwiftUI + +extension FileManagerView { + struct FileManagerListing: View { + @EnvironmentObject var fileManager: RemoteFileManager + @EnvironmentObject var device: Device + + @Environment(\.path) var navigationPath + @Environment(\.dismiss) var dismiss + + @State private var _elements: [ExtendedElement] = [] + @State private var isLoading = true + @State private var error: String? + + @State private var isForceDeletePresented = false + @State private var isFileImporterPresented = false + @State private var showOptions = false + + @State private var selectedElement: ExtendedElement? + @State private var deletedElement: ExtendedElement? + + // MARK: Create File/Directory + @FocusState var isNameFocused: Bool + @State private var newElement: FileManagerNewElement? + + @AppStorage(.fileManagerSettings) + private var settings: FileManagerSettings = .init() + + let path: Peripheral.Path + + private var title: String { + path.isRoot ? "File Manager" : path.lastComponent ?? "/" + } + + var elements: [ExtendedElement] { + settings.isHiddenFilesShow + ? _elements + : _elements.filter { !$0.name.hasPrefix(".") } + } + + var storage: StorageSpace? { + device.storageInfo?.external + } + + var body: some View { + Group { + if let error = error { + Text(error) + } else if isLoading { + ProgressView() + } else { + FixedScrollView(showsIndicators: false) { + Group { + if path.isRoot { + SDCardInfo(storage: storage) + } else { + NavigationPathView(path: path) + } + } + .padding([.horizontal, .top], 14) + + if let newElement { + TextField( + newElement.namePlaceholder, + text: Binding( + get: { self.newElement?.name ?? "" }, + set: { self.newElement?.name = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .onSubmit { submitNewElement() } + .focused($isNameFocused) + .padding([.horizontal, .top], 14) + } + + Group { + if elements.isEmpty { + EmptyFolder(onUpload: showUpload) + } else { + FileManagerElements( + elements: elements, + displayType: settings.displayType, + onTap: navigate, + onDelete: { deleteFile($0) }, + onSelect: { selectedElement = $0 } + ) + } + } + .padding(14) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.background) + .navigationBarBackground(Color.a1) + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + LeadingToolbarItems { + BackButton { + dismiss() + } + } + + PrincipalToolbarItems(alignment: .leading) { + Title(title) + } + + TrailingToolbarItems { + EllipsisButton { + showOptions = true + } + .disabled(isLoading || error != nil) + } + } + .popup(isPresented: $showOptions) { + FileListingOptions( + isPresented: $showOptions, + settings: $settings, + createFolder: { newElement(isDirectory: true) }, + createFile: { newElement(isDirectory: false) }, + upload: showUpload + ) + } + .sheet(item: $selectedElement) { + SelectedElementSheet( + element: $0, + onExport: downloadFile, + onDelete: { deleteFile($0) } + ) + } + .alert( + "Directory is not empty", + isPresented: $isForceDeletePresented, + presenting: deletedElement + ) { deletedElement in + Button("Force Delete", role: .destructive) { + deleteFile(deletedElement, force: true) + } + } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [UTType.item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + importFiles(urls) + case .failure(let error): + self.error = String(describing: error) + } + } + .task { await load() } + .refreshable { await load() } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + + do { + _elements = try await fileManager.list(at: path) + } catch { + self.error = String(describing: error) + } + } + + private func navigate(_ extended: ExtendedElement) { + switch extended.type { + case .directory(let directory): + let nextPath = path.appending(directory.name) + navigationPath.append(Destination.listing(nextPath)) + case .file(let file): + let nextPath = path.appending(file.name) + navigationPath.append(Destination.editor(nextPath)) + } + } + + private func importFiles(_ urls: [URL]) { + isLoading = true + defer { isLoading = false } + + Task { + do { + try await urls.forEach { url in + try await fileManager.importFile(url: url, at: path) + } + await load() + } catch { + self.error = String(describing: error) + } + } + } + + private func downloadFile(_ extended: ExtendedElement) { + isLoading = true + defer { isLoading = false } + + guard case let .file(file) = extended.type else { return } + + Task { + do { + let bytes = try await fileManager.readRaw( + at: path.appending(file.name)) + let url = try FileManager.default.createTempFile( + name: file.name, + data: .init(bytes)) + share(url) { + try? FileManager.default.removeItem(at: url) + // Sheet close before present share activity + selectedElement = nil + } + } catch { + self.error = String(describing: error) + } + } + } + + private func deleteFile( + _ extended: ExtendedElement, + force: Bool = false + ) { + deletedElement = extended + isLoading = true + defer { isLoading = false } + + Task { + defer { selectedElement = nil } + do { + try await fileManager.delete( + extended.type, + at: path, + force: force + ) + await load() + } catch let error as RemoteFileManager.Error + where error == .directoryIsNotEmpty && !force { + self.isForceDeletePresented = true + } catch { + self.error = String(describing: error) + } + } + } + + private func showUpload() { + isFileImporterPresented = true + } + + // MARK: Create File/Directory + func newElement(isDirectory: Bool) { + newElement = .init(name: "", isNewDirectory: isDirectory) + isNameFocused = true + } + + func submitNewElement() { + guard let newElement = newElement else { return } + let name = newElement.name + let isNewDirectory = newElement.isNewDirectory + + if !name.isEmpty { + let path = path.appending(name) + let isDirectory = isNewDirectory + Task { + do { + try await fileManager.create( + path: path, + isDirectory: isDirectory) + await load() + } catch { + self.error = String(describing: error) + } + } + } + self.newElement = nil + } + } +} + +fileprivate extension Peripheral.Path { + var isRoot: Bool { + self == "/ext" + } +} + +private struct FixedScrollView: View { + let showsIndicators: Bool + let content: () -> Content + + init( + showsIndicators: Bool, + @ViewBuilder content: @escaping () -> Content + ) { + self.showsIndicators = showsIndicators + self.content = content + } + + var body: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: showsIndicators) { + VStack(spacing: 0) { + content() + Spacer() + } + .frame(minHeight: geometry.size.height) + } + .frame(width: geometry.size.width) + } + } +} diff --git a/Flipper/iOS/UI/FileManager/FileManagerModels.swift b/Flipper/iOS/UI/FileManager/FileManagerModels.swift new file mode 100644 index 000000000..e40207b59 --- /dev/null +++ b/Flipper/iOS/UI/FileManager/FileManagerModels.swift @@ -0,0 +1,65 @@ +import Foundation + +struct FileManagerSettings: Codable, RawRepresentable { + var isHiddenFilesShow: Bool = false + var displayType: DisplayType = .list + + enum DisplayType: String, Codable { + case list + case grid + } + + enum CodingKeys: String, CodingKey { + case isHiddenFilesShow + case displayType + } + + init() {} + + var rawValue: String { + guard let data = try? JSONEncoder().encode(self) else { return "" } + return String(decoding: data, as: UTF8.self) + } + + init?(rawValue: String) { + guard + let value = try? JSONDecoder().decode( + Self.self, + from: .init(rawValue.utf8) + ) + else { + return nil + } + self = value + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isHiddenFilesShow = try container.decode( + Bool.self, + forKey: .isHiddenFilesShow) + displayType = try container.decode( + DisplayType.self, + forKey: .displayType) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(isHiddenFilesShow, forKey: .isHiddenFilesShow) + try container.encode(displayType, forKey: .displayType) + } +} + +struct FileManagerNewElement { + var name: String + let isNewDirectory: Bool + + init(name: String, isNewDirectory: Bool) { + self.name = name + self.isNewDirectory = isNewDirectory + } + + var namePlaceholder: String { + "\(isNewDirectory ? "directory" : "file") name" + } +} diff --git a/Flipper/iOS/UI/Options/FileManager/FileManagerView.swift b/Flipper/iOS/UI/FileManager/FileManagerView.swift similarity index 100% rename from Flipper/iOS/UI/Options/FileManager/FileManagerView.swift rename to Flipper/iOS/UI/FileManager/FileManagerView.swift diff --git a/Flipper/iOS/UI/Options/FileManager/FileManagerEditor.swift b/Flipper/iOS/UI/Options/FileManager/FileManagerEditor.swift deleted file mode 100644 index b1bd7614b..000000000 --- a/Flipper/iOS/UI/Options/FileManager/FileManagerEditor.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Core -import Peripheral - -import SwiftUI - -extension FileManagerView { - struct FileManagerEditor: View { - let path: Peripheral.Path - - @EnvironmentObject var fileManager: RemoteFileManager - @Environment(\.dismiss) var dismiss - - @State private var content: String = "" - @State private var error: String? - @State private var isBusy = false - - var body: some View { - VStack { - if let error = error { - Text(error) - } else if isBusy { - ProgressView() - } else { - Card { - TextEditor(text: $content) - .hideScrollBackground() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .padding(14) - } - } - .navigationBarBackground(Color.a1) - .navigationBarBackButtonHidden(true) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - LeadingToolbarItems { - BackButton { - dismiss() - } - } - PrincipalToolbarItems(alignment: .leading) { - Title(path.lastComponent ?? "") - } - TrailingToolbarItems { - SaveButton { - Task { - await save() - } - } - } - } - .padding(.bottom, 16) - .task { - await load() - } - } - - func load() async { - do { - isBusy = true - defer { isBusy = false } - content = try await fileManager.readFile(at: path) - } catch { - self.error = String(describing: error) - } - } - - func save() async { - isBusy = true - do { - try await fileManager.writeFile(content, at: path) - } catch { - self.error = String(describing: error) - } - isBusy = false - } - } -} diff --git a/Flipper/iOS/UI/Options/FileManager/FileManagerListing.swift b/Flipper/iOS/UI/Options/FileManager/FileManagerListing.swift deleted file mode 100644 index 60e8b1206..000000000 --- a/Flipper/iOS/UI/Options/FileManager/FileManagerListing.swift +++ /dev/null @@ -1,277 +0,0 @@ -import Core -import Peripheral - -import SwiftUI -import UniformTypeIdentifiers - -extension FileManagerView { - struct FileManagerListing: View { - @Environment(\.path) var navigationPath - - let path: Peripheral.Path - - @EnvironmentObject var fileManager: RemoteFileManager - @Environment(\.dismiss) var dismiss - - @State private var elements: [Element] = [] - @State private var error: String? - @State private var isBusy = false - - @State private var name = "" - @State private var isNewFile = false - @State private var isNewDirectory = false - @FocusState var isNameFocused: Bool - var namePlaceholder: String { - "\(isNewFile ? "file" : "directory") name" - } - - @State private var selectedIndexSet: IndexSet? - @State private var isForceDeletePresented = false - @State private var isFileImporterPresented = false - - var body: some View { - VStack { - if isBusy { - ProgressView() - } else if let error = error { - Text(error) - } else { - List { - if !path.isEmpty { - Button("..") { - dismiss() - } - .foregroundColor(.primary) - } - if isNewFile || isNewDirectory { - TextField(namePlaceholder, text: $name) - .onSubmit { - submitNewElement() - } - .focused($isNameFocused) - } - ForEach(elements, id: \.description) { - switch $0 { - case .directory(let directory): - NavigationLink(value: Destination.listing( - path.appending(directory.name) - )) { - DirectoryRow(directory: directory) - } - .foregroundColor(.primary) - case .file(let file): - HStack { - FileRow(file: file) - .onTapGesture { - navigationPath.append( - Destination.editor( - path.appending(file.name) - ) - ) - } - DownloadFileIcon() - .onTapGesture { - Task { - await downloadFile(file) - } - } - } - } - } - .onDelete { indexSet in - Task { - await delete(indexSet) - } - } - } - } - } - .navigationBarBackground(Color.a1) - .navigationBarBackButtonHidden(true) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - LeadingToolbarItems { - BackButton { - dismiss() - } - } - PrincipalToolbarItems(alignment: .leading) { - Title(path.lastComponent ?? "/") - } - TrailingToolbarItems { - if !path.isEmpty { - NavBarMenu { - Button { - newElement(isDirectory: false) - } label: { - Text("File") - } - - Button { - newElement(isDirectory: true) - } label: { - Text("Folder") - } - - Button { - isFileImporterPresented = true - } label: { - Text("Import") - } - } label: { - Image(systemName: "plus") - } - } - } - } - .alert( - "Directory is not empty", - isPresented: $isForceDeletePresented, - presenting: selectedIndexSet - ) { selectedIndexSet in - Button("Force Delete", role: .destructive) { - Task { - await delete(selectedIndexSet, force: true) - } - } - } - .fileImporter( - isPresented: $isFileImporterPresented, - allowedContentTypes: [UTType.item] - ) { result in - if case .success(let url) = result { - Task { - await importFile(url) - } - } - } - .task { - await list() - } - } - - func showingProgress(_ task: () async throws -> Void) async throws { - isBusy = true - defer { isBusy = false } - try await task() - } - - func list() async { - do { - try await showingProgress { - elements = try await fileManager.list(at: path) - } - } catch { - self.error = String(describing: error) - } - } - - func delete(_ indexSet: IndexSet, force: Bool = false) async { - guard let index = indexSet.first else { return } - let element = elements.remove(at: index) - do { - try await fileManager.delete(element, at: path, force: force) - } catch let error as RemoteFileManager.Error - where error == .directoryIsNotEmpty && !force { - elements.insert(element, at: index) - self.selectedIndexSet = indexSet - self.isForceDeletePresented = true - } catch { - self.error = String(describing: error) - elements.insert(element, at: index) - } - } - - func newElement(isDirectory: Bool) { - name = "" - isNewFile = !isDirectory - isNewDirectory = isDirectory - isNameFocused = true - } - - func submitNewElement() { - if !name.isEmpty { - let path = path.appending(name) - let isDirectory = isNewDirectory - Task { - do { - try await fileManager.create( - path: path, - isDirectory: isDirectory) - await list() - } catch { - self.error = String(describing: error) - } - } - } - name = "" - isNewFile = false - isNewDirectory = false - } - - func importFile(_ url: URL) async { - do { - try await showingProgress { - try await fileManager.importFile(url: url, at: path) - } - await list() - } catch { - self.error = String(describing: error) - } - } - - func downloadFile(_ file: File) async { - isBusy = true - defer { isBusy = false } - do { - let bytes = try await fileManager.readRaw( - at: path.appending(file.name)) - let url = try FileManager.default.createTempFile( - name: file.name, - data: .init(bytes)) - share(url) { - try? FileManager.default.removeItem(at: url) - } - } catch { - self.error = String(describing: error) - } - } - } -} - -extension FileManagerView.FileManagerListing { - struct DirectoryRow: View { - let directory: Directory - - var body: some View { - HStack { - Image(systemName: "folder.fill") - .frame(width: 20) - - Text(directory.name) - } - } - } - - struct FileRow: View { - let file: File - - var body: some View { - HStack { - Image(systemName: "doc") - .frame(width: 20) - Text(file.name) - Spacer() - Text("\(file.size) bytes") - } - .contentShape(Rectangle()) - } - } - - struct DownloadFileIcon: View { - var body: some View { - Image(systemName: "icloud.and.arrow.down") - .frame(width: 20, height: 20) - } - } -} diff --git a/Flipper/iOS/UI/Options/OptionsView.swift b/Flipper/iOS/UI/Options/OptionsView.swift index 12ddb6c71..cd59e0a06 100644 --- a/Flipper/iOS/UI/Options/OptionsView.swift +++ b/Flipper/iOS/UI/Options/OptionsView.swift @@ -25,7 +25,6 @@ struct OptionsView: View { case stressTest case speedTest case logs - case fileManager case reportBug case infrared } @@ -55,9 +54,6 @@ struct OptionsView: View { } Section(header: Text("Remote")) { - NavigationLink(value: Destination.fileManager) { - Text("File Manager") - } Button("Reboot Flipper") { device.reboot() } @@ -155,7 +151,6 @@ struct OptionsView: View { case .stressTest: StressTestView() case .speedTest: SpeedTestView() case .logs: LogsView() - case .fileManager: FileManagerView() case .reportBug: ReportBugView() case .infrared: InfraredDebugLayout() }