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()
}