diff --git a/piPhone/Apps/AppsView.swift b/piPhone/Apps/AppsView.swift index 0dd18ba..1c57f13 100644 --- a/piPhone/Apps/AppsView.swift +++ b/piPhone/Apps/AppsView.swift @@ -13,154 +13,30 @@ import TreeSitterJavaScriptRunestone import TreeSitterPythonRunestone import UIKit -struct AppItem: Identifiable { - var id = UUID() - var title: String - var icon: String -} - -enum CodeLanguage: Equatable { - case plainText - case javaScript - case python - case java - - static func fromFilename(_ name: String) -> CodeLanguage { - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - let ext = (trimmed as NSString).pathExtension.lowercased() - - switch ext { - case "js", "jsx", "mjs", "cjs": - return .javaScript - case "py": - return .python - case "java": - return .java - default: - return .plainText - } - } -} - -struct FileOption: Identifiable, Equatable { - let id: String - var name: String - var url: String - var code: String - var language: CodeLanguage - - init( - id: String = UUID().uuidString, - name: String, - url: String, - code: String = "", - language: CodeLanguage = .plainText - ) { - self.id = id - self.name = name - self.url = url - self.code = code - self.language = language - } -} - -struct RunestoneEditorView: UIViewRepresentable { - @Binding var text: String - var language: CodeLanguage - - func makeUIView(context: Context) -> TextView { - let tv = TextView() - tv.editorDelegate = context.coordinator - - tv.theme = RunestoneTheme() - tv.backgroundColor = UIColor.clear - - tv.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) - tv.showLineNumbers = true - tv.lineHeightMultiplier = 1.2 - tv.kern = 0.3 - - tv.characterPairs = defaultPairs - tv.indentStrategy = .space(length: 4) - tv.autocorrectionType = .no - tv.autocapitalizationType = .none - tv.smartQuotesType = .no - tv.smartDashesType = .no - tv.smartInsertDeleteType = .no - tv.spellCheckingType = .no - - tv.text = text - applyLanguage(to: tv, language: language) - - return tv - } - - func updateUIView(_ tv: TextView, context: Context) { - if tv.text != text { - tv.text = text - } - - if context.coordinator.lastLanguage != language { - context.coordinator.lastLanguage = language - applyLanguage(to: tv, language: language) - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(text: $text, lastLanguage: language) - } - - final class Coordinator: NSObject, TextViewDelegate { - var text: Binding - var lastLanguage: CodeLanguage - - init(text: Binding, lastLanguage: CodeLanguage) { - self.text = text - self.lastLanguage = lastLanguage - } - } - - private func applyLanguage(to tv: TextView, language: CodeLanguage) { - switch language { - case .plainText: - tv.setLanguageMode(PlainTextLanguageMode()) - - case .javaScript: - tv.setLanguageMode(TreeSitterLanguageMode(language: .javaScript)) - - case .python: - tv.setLanguageMode(TreeSitterLanguageMode(language: .python)) - - case .java: - tv.setLanguageMode(TreeSitterLanguageMode(language: .java)) - } - } -} - +// MARK: - Main View struct AppsView: View { @State private var showAddSheet = false @State private var newTitle = "" @State private var newIcon = "" - @State private var newFileId = "none" + @State private var newExecutableName = "none" @State private var searchText = "" @State private var appPendingDelete: AppItem? = nil @State private var showDeleteAlert = false + @State private var newColor: Color = .blue + @Environment(\.horizontalSizeClass) private var hSize + @Environment(\.verticalSizeClass) private var vSize + // MARK: - App Item Mock Data @State private var apps: [AppItem] = [ - .init(title: "UI change", icon: "photo"), - .init(title: "Take a Break", icon: "timer"), - .init(title: "Add new Task", icon: "map"), - .init(title: "Add new Apple", icon: "phone"), - .init(title: "Add new Ticket", icon: "map"), - .init(title: "Should add a new Ticket", icon: "headphones"), - .init(title: "Change Theme", icon: "envelope"), - .init(title: "Phone details", icon: "phone"), - .init(title: "Take that dollar", icon: "dollarsign"), - .init(title: "CreditCard", icon: "creditcard"), + .init(title: "Hi", icon: "photo", executableName: "his.py", color: .blue), + .init( + title: "Take a Break please!", icon: "timer", executableName: "break.sh", color: .green), ] private var columns: [GridItem] { - [GridItem(.adaptive(minimum: 110))] + let isLandscape = (hSize == .regular && vSize == .compact) || (vSize == .compact) + let count = isLandscape ? 2 : 1 + return Array(repeating: GridItem(.flexible(), spacing: 12), count: count) } private var filteredApps: [AppItem] { @@ -173,15 +49,27 @@ struct AppsView: View { let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - let newItem = AppItem(title: trimmed, icon: newIcon) + let iconToSave = + newIcon.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "pc" + : newIcon + + let newItem = AppItem( + title: trimmed, + icon: iconToSave, + executableName: newExecutableName, + color: newColor + ) + apps.append(newItem) newTitle = "" newIcon = "" - newFileId = "none" + newExecutableName = "none" showAddSheet = false } + // MARK: view var body: some View { NavigationStack { ZStack { @@ -189,47 +77,44 @@ struct AppsView: View { .ignoresSafeArea() ScrollView { - LazyVGrid(columns: columns, spacing: 12) { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 300), spacing: 12)], + spacing: 12 + ) { ForEach(filteredApps) { item in NavigationLink { AppDetailView(item: item) } label: { - VStack(spacing: 8) { - AppCard(item: item) - .contentShape( - .contextMenuPreview, - RoundedRectangle(cornerRadius: 16, style: .continuous) - ) - .contextMenu { - Button { - } label: { - Label("Edit", systemImage: "pencil") - } - Button { - let copy = AppItem( - title: item.title, icon: item.icon) - apps.append(copy) - } label: { - Label("Duplicate", systemImage: "doc.on.doc") - } - - Button(role: .destructive) { - appPendingDelete = item - showDeleteAlert = true - } label: { - Label("Delete", systemImage: "trash") - } + AppCard(item: item) + .contentShape( + .contextMenuPreview, + RoundedRectangle(cornerRadius: 24, style: .continuous) + ) + .contextMenu { + Button { + } label: { + Label("Edit", systemImage: "pencil") + } + Button { + let copy = AppItem( + title: item.title, + icon: item.icon, + executableName: item.executableName, + color: item.color + ) + apps.append(copy) + } label: { + Label("Duplicate", systemImage: "doc.on.doc") } - Text(item.title) - .font(.footnote) - .foregroundStyle(Color(.label)) - .lineLimit(1) - .minimumScaleFactor(0.75) - .frame(width: 90) - .multilineTextAlignment(.center) - } + Button(role: .destructive) { + appPendingDelete = item + showDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + } } .buttonStyle(.plain) } @@ -242,7 +127,7 @@ struct AppsView: View { .searchable( text: $searchText, placement: .navigationBarDrawer(displayMode: .always), - prompt: "Search apps" + prompt: "Search" ) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -274,7 +159,8 @@ struct AppsView: View { AddAppSheet( title: $newTitle, icon: $newIcon, - selectedFileId: $newFileId, + selectedExecutableName: $newExecutableName, + selectedColor: $newColor, onAdd: { addNewApp() } ) } @@ -282,27 +168,61 @@ struct AppsView: View { } } +// MARK: - Model +struct AppItem: Identifiable { + var id = UUID() + var title: String + var icon: String + var executableName: String + var color: Color +} + +// MARK: - App Card View struct AppCard: View { let item: AppItem var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color(.separator).opacity(0.35), lineWidth: 1) - ) + HStack(spacing: 14) { Image(systemName: item.icon) - .font(.system(size: 30, weight: .semibold)) - .foregroundStyle(Color(.label)) + .font(.system(size: 25, weight: .semibold)) + .foregroundStyle(item.color) + .frame(width: 72, height: 68) + + VStack(spacing: 2) { + Text(item.title) + .font(.system(size: 19, weight: .semibold)) + .lineLimit(1) + .multilineTextAlignment(.center) + + Text(item.executableName) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 8) + + VStack(alignment: .trailing, spacing: 2) { + Text("Success") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.green) + } + .frame(minWidth: 70, alignment: .trailing) + .padding(.trailing, 15) } - .frame(width: 72, height: 72) - .shadow(color: Color.black.opacity(0.10), radius: 6, x: 0, y: 3) + .padding(.vertical, 14) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 50, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) } } +// MARK: - App Icon Cell View struct AppIconCell: View { let item: AppItem @@ -321,6 +241,7 @@ struct AppIconCell: View { } } +// MARK: - Destination Screens struct AppDetailView: View { let item: AppItem @@ -339,280 +260,251 @@ struct AppDetailView: View { } } -struct FileRow: View { - let title: String - let value: String - - var body: some View { - HStack { - Text("Choose a file:") - .foregroundColor(.secondary) - - Spacer() - - Text(value) - .foregroundStyle(.secondary) - } - } -} - -struct FilePickerScreen: View { - @Binding var selectedFileId: String - @Binding var options: [FileOption] - - @Environment(\.dismiss) private var dismiss - @State private var showAddFileSheet = false - - var body: some View { - List { - ForEach(options) { opt in - Button { - selectedFileId = (selectedFileId == opt.id) ? "none" : opt.id - } label: { - HStack { - Text(opt.name) - .foregroundStyle(.primary) - - Spacer() - - if opt.id == selectedFileId { - Image(systemName: "checkmark") - .foregroundStyle(.primary) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .navigationTitle("File") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showAddFileSheet = true - } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showAddFileSheet) { - AddFileSheet { newFile in - options.append(newFile) - selectedFileId = newFile.id - } - } - } -} - +// MARK: - Add App Sheet struct AddAppSheet: View { @Binding var title: String @Binding var icon: String - @Binding var selectedFileId: String + @Binding var selectedExecutableName: String + @Binding var selectedColor: Color + @State private var scrollMinY: CGFloat = 0 let onAdd: () -> Void - @State private var fileOptions: [FileOption] = [ - FileOption(id: "f1", name: "projects.pdf", url: "files://projects") - ] - - private let iconCategories: [(title: String, symbols: [String])] = [ - ( - "Communication", - [ - "mic.fill", "message.fill", "phone.fill", - "video.fill", "envelope.fill", - ] - ), - ( - "Weather", - [ - "sun.max.fill", "moon.fill", - "cloud.fill", "cloud.rain.fill", - ] + @State private var executableOptions: [Executable] = [ + Executable( + id: "f1", + name: "projects.pdf", + url: "files://projects.pdf", + language: .plainText, + createdAt: Date(timeIntervalSinceNow: -60 * 60 * 24 * 3), + sizeBytes: 2_048 ), - ( - "Objects & Tools", - [ - "folder.fill", "paperclip", - "link", "book.fill", - "trash.fill", "gearshape.fill", - "eraser.fill", "graduationcap.fill", - "ruler.fill", "backpack.fill", - ] + Executable( + id: "f2", + name: "test.py", + url: "files://test.py", + language: .python, + createdAt: Date(timeIntervalSinceNow: -60 * 60 * 24), + sizeBytes: 4_321 ), - ( - "Devices", - [ - "keyboard.fill", "printer.fill", - "desktopcomputer", "macpro.gen2", "pc", - "airtag.fill", "macpro.gen3.fill", "display", - "iphone.gen2", - ] + Executable( + id: "f3", + name: "person.java", + url: "files://person.java", + language: .java, + createdAt: Date(timeIntervalSinceNow: -60 * 60 * 12), + sizeBytes: 9_812 ), - ( - "Nature", - [ - "globe.europe.africa", "sun.min.fill", - "cloud.sun.fill", "sun.max.fill", - "sunrise.fill", "moon.fill", - "sparkles", "moon.stars", - "cloud.fill", "cloud.heavyrain.fill", - "wind", "snowflake", "leaf", "bolt", - ] + Executable( + id: "f4", + name: "laravel.php", + url: "files://laravel.php", + language: .plainText, + createdAt: Date(), + sizeBytes: 15_672 ), ] - private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 5) + // MARK: - Colors + private let focusColors: [Color] = [ + .blue, + .teal, + .green, + .yellow, + .orange, + .red, + .pink, + .purple, + .indigo, + .gray, + ] + + // MARK: - Icons + private let icons: [String] = [ + "mic.fill", "message.fill", "phone.fill", + "video.fill", "envelope.fill", + "sun.max.fill", "moon.fill", + "cloud.fill", "cloud.rain.fill", + "folder.fill", "paperclip", + "link", "book.fill", + "trash.fill", "gearshape.fill", + "eraser.fill", "graduationcap.fill", + "ruler.fill", "backpack.fill", + "globe.europe.africa", "sun.min.fill", + "cloud.sun.fill", "sun.max", + "sunrise.fill", "moon", + "sparkles", "moon.stars", + "cloud", "cloud.heavyrain.fill", + "wind", "snowflake", "leaf", "bolt", + "keyboard.fill", "printer.fill", + "desktopcomputer", "macpro.gen2", "pc", + "airtag.fill", "macpro.gen3.fill", "display", + "iphone.gen2", + ] + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 6) - private var selectedFileName: String { - fileOptions.first(where: { $0.id == selectedFileId })?.name ?? "None" + private var canGoNext: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private struct ScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } + } + @Environment(\.dismiss) private var dismiss + var body: some View { NavigationStack { - Form { - Section("File") { - NavigationLink { - FilePickerScreen( - selectedFileId: $selectedFileId, - options: $fileOptions - ) - } label: { - FileRow(title: "File", value: selectedFileName) - } - } - - Section("App Info") { - TextField("App name", text: $title) - } - - Section("Icon") { + ZStack { + Color(.systemGroupedBackground).ignoresSafeArea() - // Selected icon preview - HStack(spacing: 12) { - Image(systemName: icon) - .font(.title2) - .scaleEffect(1.3) - } + ScrollView { + VStack(spacing: 18) { + + Text("Create New App") + .font(.system(size: 28, weight: .bold)) + .padding(.top, 50) + + ZStack { + Circle() + .fill(selectedColor) + .opacity(0.9) + Image(systemName: icon.isEmpty ? "pc" : icon) + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(Color(.label)) + } + .frame(width: 84, height: 84) + .padding(.top, 6) + + TextField("Name", text: $title) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(selectedColor) + .multilineTextAlignment(.center) + .textInputAutocapitalization(.words) + .padding(.vertical, 14) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .padding(.horizontal, 22) + + let cellSize: CGFloat = 44 + let ringWidth: CGFloat = 3 + let gap: CGFloat = 3 + let outerSize = cellSize + 2 * (gap + ringWidth) + let bg = Color(.systemGroupedBackground) + + let columns: [GridItem] = Array( + repeating: GridItem(.fixed(cellSize), spacing: 17), count: 6) + + LazyVGrid(columns: columns, spacing: 20) { + + ForEach(focusColors.indices, id: \.self) { index in + let color = focusColors[index] + + Button { + selectedColor = color + } label: { + ZStack { + Circle() + .fill(color) + .frame(width: 46, height: 46) + + if selectedColor == color { + Circle() + .stroke(bg, lineWidth: ringWidth + gap * 2) + .frame( + width: cellSize + gap * 2, + height: cellSize + gap * 2) + + Circle() + .stroke(Color.blue, lineWidth: ringWidth) + .frame( + width: cellSize + 2 * (gap + ringWidth / 2), + height: cellSize + 2 * (gap + ringWidth / 2)) + } + } + .frame(width: outerSize, height: outerSize) + } + } - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 16) { - - ForEach(iconCategories, id: \.title) { category in - VStack(alignment: .leading, spacing: 8) { - Text(category.title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - - LazyVGrid(columns: columns, spacing: 10) { - ForEach(category.symbols, id: \.self) { name in - Button { - icon = name - } label: { - Image(systemName: name) - .font(.title) - .frame(maxWidth: .infinity, minHeight: 36) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 10) - .fill( - name == icon - ? Color.primary.opacity(0.15) - : Color.clear) - ) - } - .buttonStyle(.plain) + ForEach(icons, id: \.self) { name in + Button { + icon = name + } label: { + ZStack { + Circle() + .fill( + icon == name + ? Color.blue.opacity(0.20) + : Color(.secondarySystemGroupedBackground) + ) + .frame(width: cellSize, height: cellSize) + + Image(systemName: name) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle( + icon == name + ? Color.blue.opacity(0.85) + : Color(.secondaryLabel) + ) + + if icon == name { + Circle() + .stroke(bg, lineWidth: ringWidth + gap * 2) + .frame( + width: cellSize + gap * 2, + height: cellSize + gap * 2) + + Circle() + .stroke(Color.blue, lineWidth: ringWidth) + .frame( + width: cellSize + 2 * (gap + ringWidth / 2), + height: cellSize + 2 * (gap + ringWidth / 2)) } } + .frame(width: outerSize, height: outerSize) } } } - .padding(.vertical, 6) + .padding(.horizontal, 16) + .padding(.top, 6) + + Spacer().frame(height: 90) } - .frame(maxHeight: .infinity) } - } - .scrollDismissesKeyboard(.interactively) - .navigationTitle("New App") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { onAdd() } - .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - } -} - -struct AddFileSheet: View { - @Environment(\.dismiss) private var dismiss - - @State private var name: String = "" - @State private var code: String = "" - let onSave: (FileOption) -> Void - - private var detectedLanguage: CodeLanguage { - CodeLanguage.fromFilename(name) - } - - var body: some View { - NavigationStack { - Form { - Section("File Info") { - TextField("File name (e.g. notes.py)", text: $name) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + VStack { + Spacer() + NavigationLink { + ExecutablePickerScreen( + selectedExecutableName: $selectedExecutableName, + options: $executableOptions, + onCreate: onAdd + ) + } label: { + Text("Next") + .font(.system(size: 17, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(canGoNext ? Color.accentColor : Color(.systemGray3)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .disabled(!canGoNext) + .padding(.horizontal, 16) + .padding(.bottom, 14) } + .ignoresSafeArea(.keyboard, edges: .bottom) - Section("Code") { - RunestoneEditorView(text: $code, language: detectedLanguage) - .frame(minHeight: 550) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - } } .scrollDismissesKeyboard(.interactively) - .navigationTitle("Add File") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - let newFile = FileOption( - name: trimmed, - url: "files://\(trimmed)", - code: code - ) - onSave(newFile) - dismiss() - } - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } } } } - -struct SimplePair: CharacterPair { - let leading: String - let trailing: String -} - -private let defaultPairs: [CharacterPair] = [ - SimplePair(leading: "{", trailing: "}"), - SimplePair(leading: "(", trailing: ")"), - SimplePair(leading: "[", trailing: "]"), - SimplePair(leading: "\"", trailing: "\""), - SimplePair(leading: "'", trailing: "'"), -] - #Preview { - ContentView() + AppsView() } diff --git a/piPhone/Apps/Executable/AddExecutableView.swift b/piPhone/Apps/Executable/AddExecutableView.swift new file mode 100644 index 0000000..5237230 --- /dev/null +++ b/piPhone/Apps/Executable/AddExecutableView.swift @@ -0,0 +1,69 @@ +// +// AddExecutableView.swift +// piPhone +// +// Created by Eris Leci on 1/25/26. +// + +import SwiftUI + +struct AddExecutableSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var name: String = "" + @State private var code: String = "" + + let onSave: (Executable) -> Void + + private var detectedLanguage: CodeLanguage { + CodeLanguage.fromExecutableName(name) + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name (e.g. notes.py)", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section("Code") { + RunestoneView(text: $code, language: detectedLanguage) + .frame(minHeight: 550) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Add Executable") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let utf8Count = code.lengthOfBytes(using: .utf8) + + let newFile = Executable( + name: trimmed, + url: "files://\(trimmed)", + code: code, + language: detectedLanguage, + createdAt: Date(), + sizeBytes: utf8Count + ) + + onSave(newFile) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} diff --git a/piPhone/Apps/Executable/EmptyExecutableView.swift b/piPhone/Apps/Executable/EmptyExecutableView.swift new file mode 100644 index 0000000..18e3378 --- /dev/null +++ b/piPhone/Apps/Executable/EmptyExecutableView.swift @@ -0,0 +1,51 @@ +// +// EmptyExecutableView.swift +// piPhone +// +// Created by Eris Leci on 1/25/26. +// + +import SwiftUI + +struct EmptyStateView: View { + let systemImage: String + let title: String + let subtitle: String + + var body: some View { + VStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 44, weight: .regular)) + .foregroundColor(Color.accentColor) + + Text(title) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.primary) + + Text(subtitle) + .font(.system(size: 15)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + .padding(.horizontal, 24) + } +} + +struct ExecutablesEmptyState: View { + var body: some View { + if #available(iOS 17.0, *) { + ContentUnavailableView( + "No Executables", + systemImage: "sparkles", + description: Text("Tap + to add your first executable.") + ) + } else { + EmptyStateView( + systemImage: "sparkles", + title: "No Executables", + subtitle: "Tap + to add your first executable." + ) + } + } +} diff --git a/piPhone/Apps/Executable/ExecutableRowView.swift b/piPhone/Apps/Executable/ExecutableRowView.swift new file mode 100644 index 0000000..0892e07 --- /dev/null +++ b/piPhone/Apps/Executable/ExecutableRowView.swift @@ -0,0 +1,52 @@ +// +// ExecutableRowView.swift +// piPhone +// +// Created by Eris Leci on 1/25/26. +// + +import SwiftUI + +struct ExecutableRow: View { + let executable: Executable + let isSelected: Bool + private let iconName = "doc" + + private var metaText: String { + let date = executable.createdAt.formatted(date: .numeric, time: .omitted) + let size = ByteCountFormatter.string( + fromByteCount: Int64(executable.sizeBytes), + countStyle: .file + ) + return "\(date) \u{2022} \(size)" + } + + var body: some View { + HStack(spacing: 12) { + + Image(systemName: iconName) + .font(.system(size: 30)) + .foregroundStyle(isSelected ? Color.accentColor : Color(.secondaryLabel)) + .frame(width: 34) + + VStack(alignment: .leading, spacing: 4) { + Text(executable.name) + .font(.system(size: 17)) + .foregroundColor(isSelected ? .accentColor : .primary) + + Text(metaText) + .font(.system(size: 13)) + .foregroundColor(isSelected ? .accentColor : .secondary) + } + + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.accentColor) + } + } + .padding(.vertical, 10) + .contentShape(Rectangle()) + } +} diff --git a/piPhone/Apps/Executable/ExecutableView.swift b/piPhone/Apps/Executable/ExecutableView.swift new file mode 100644 index 0000000..6a6bdf0 --- /dev/null +++ b/piPhone/Apps/Executable/ExecutableView.swift @@ -0,0 +1,133 @@ +// +// ExecutableView.swift +// piPhone +// +// Created by Gentris Leci on 1/12/26. +// + +import SwiftUI + +// MARK: - Executable Model +struct Executable: Identifiable, Equatable { + let id: String + var name: String + var url: String + var code: String + var language: CodeLanguage + + var createdAt: Date + var sizeBytes: Int + + init( + id: String = UUID().uuidString, + name: String, + url: String, + code: String = "", + language: CodeLanguage = .plainText, + createdAt: Date = Date(), + sizeBytes: Int = 0 + ) { + self.id = id + self.name = name + self.url = url + self.code = code + self.language = language + self.createdAt = createdAt + self.sizeBytes = sizeBytes + } +} + +// MARK: - Pick executable screen +struct ExecutablePickerScreen: View { + @State private var searchText = "" + @State private var showAddExecutableSheet = false + @Binding var selectedExecutableName: String + + @Binding var options: [Executable] + @Environment(\.dismiss) private var dismiss + + let onCreate: () -> Void + + private var canCreate: Bool { selectedExecutableName != "none" } + + private var filteredExecutables: [Executable] { + let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !q.isEmpty else { return options } + return options.filter { $0.name.localizedCaseInsensitiveContains(q) } + } + + var body: some View { + ZStack { + List { + Section { + ForEach(filteredExecutables) { opt in + Button { + selectedExecutableName = + (selectedExecutableName == opt.name) ? "none" : opt.name + } label: { + ExecutableRow( + executable: opt, + isSelected: opt.name == selectedExecutableName + ) + } + .buttonStyle(.plain) + .listRowBackground( + opt.name == selectedExecutableName + ? Color.accentColor.opacity(0.08) + : Color.clear + ) + } + } + .listSectionSeparator(.hidden) + } + .listStyle(.plain) + + if options.isEmpty { + ExecutablesEmptyState() + } + + VStack { + Spacer() + Button { + onCreate() + dismiss() + } label: { + Text("Done") + .font(.system(size: 17, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(canCreate ? Color.accentColor : Color(.systemGray3)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .disabled(!canCreate) + .padding(.horizontal, 16) + .padding(.bottom, 14) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .navigationTitle("Executables") + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search" + ) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddExecutableSheet = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAddExecutableSheet) { + AddExecutableSheet { newExecutable in + options.append(newExecutable) + selectedExecutableName = newExecutable.name + } + } + } +} diff --git a/piPhone/Apps/Executable/FileView.swift b/piPhone/Apps/Executable/FileView.swift new file mode 100644 index 0000000..b8cf97b --- /dev/null +++ b/piPhone/Apps/Executable/FileView.swift @@ -0,0 +1,18 @@ +// +// FileView.swift +// piPhone +// +// Created by Eris Leci on 1/25/26. +// + +import SwiftUI + +struct FileView: View { + var body: some View { + Text( /*@START_MENU_TOKEN@*/"Hello, World!" /*@END_MENU_TOKEN@*/) + } +} + +#Preview { + FileView() +} diff --git a/piPhone/Apps/RunestoneTheme.swift b/piPhone/Apps/Runestone/RunestoneTheme.swift similarity index 100% rename from piPhone/Apps/RunestoneTheme.swift rename to piPhone/Apps/Runestone/RunestoneTheme.swift diff --git a/piPhone/Apps/Runestone/RunestoneView.swift b/piPhone/Apps/Runestone/RunestoneView.swift new file mode 100644 index 0000000..21bd008 --- /dev/null +++ b/piPhone/Apps/Runestone/RunestoneView.swift @@ -0,0 +1,122 @@ +// +// RunestoneView.swift +// piPhone +// +// Created by Eris Leci on 1/12/26. +// + +import Runestone +import SwiftUI +import TreeSitterJavaRunestone +import TreeSitterJavaScriptRunestone +import TreeSitterPythonRunestone +import UIKit + +struct RunestoneView: UIViewRepresentable { + @Binding var text: String + var language: CodeLanguage + + func makeUIView(context: Context) -> TextView { + let tv = TextView() + tv.editorDelegate = context.coordinator + + tv.theme = RunestoneTheme() + tv.backgroundColor = UIColor.clear + + tv.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) + tv.showLineNumbers = true + tv.lineHeightMultiplier = 1.2 + tv.kern = 0.3 + + tv.characterPairs = defaultPairs + tv.indentStrategy = .space(length: 4) + tv.autocorrectionType = .no + tv.autocapitalizationType = .none + tv.smartQuotesType = .no + tv.smartDashesType = .no + tv.smartInsertDeleteType = .no + tv.spellCheckingType = .no + + tv.text = text + applyLanguage(to: tv, language: language) + + return tv + } + + func updateUIView(_ tv: TextView, context: Context) { + if tv.text != text { + tv.text = text + } + + if context.coordinator.lastLanguage != language { + context.coordinator.lastLanguage = language + applyLanguage(to: tv, language: language) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, lastLanguage: language) + } + + final class Coordinator: NSObject, TextViewDelegate { + var text: Binding + var lastLanguage: CodeLanguage + + init(text: Binding, lastLanguage: CodeLanguage) { + self.text = text + self.lastLanguage = lastLanguage + } + } + + private func applyLanguage(to tv: TextView, language: CodeLanguage) { + switch language { + case .plainText: + tv.setLanguageMode(PlainTextLanguageMode()) + + case .javaScript: + tv.setLanguageMode(TreeSitterLanguageMode(language: .javaScript)) + + case .python: + tv.setLanguageMode(TreeSitterLanguageMode(language: .python)) + + case .java: + tv.setLanguageMode(TreeSitterLanguageMode(language: .java)) + } + } +} + +enum CodeLanguage: Equatable { + case plainText + case javaScript + case python + case java + + static func fromExecutableName(_ name: String) -> CodeLanguage { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let ext = (trimmed as NSString).pathExtension.lowercased() + + switch ext { + case "js", "jsx", "mjs", "cjs": + return .javaScript + case "py": + return .python + case "java": + return .java + default: + return .plainText + } + } +} + +struct SimplePair: CharacterPair { + let leading: String + let trailing: String +} + +private let defaultPairs: [CharacterPair] = [ + SimplePair(leading: "{", trailing: "}"), + SimplePair(leading: "(", trailing: ")"), + SimplePair(leading: "[", trailing: "]"), + SimplePair(leading: "\"", trailing: "\""), + SimplePair(leading: "'", trailing: "'"), +] diff --git a/piPhone/Device/DeviceView.swift b/piPhone/Device/DeviceView.swift index ee3045a..73d0d8e 100644 --- a/piPhone/Device/DeviceView.swift +++ b/piPhone/Device/DeviceView.swift @@ -24,18 +24,19 @@ struct DeviceView: View { struct DeviceRealityView: View { var body: some View { - RealityView { content in - if let model = try? await ModelEntity(named: "piPhone-20260104-final") { - model.transform.scale *= 20.0 - model.generateCollisionShapes(recursive: true) - - content.add(model) - } - Task { - // Asynchronously perform any additional work to configure - // the content after the system renders the view. - } - } + // RealityView { content in + // if let model = try? await ModelEntity(named: "piPhone-20260104-final") { + // model.transform.scale *= 20.0 + // model.generateCollisionShapes(recursive: true) + // + // content.add(model) + // } + // Task { + // // Asynchronously perform any additional work to configure + // // the content after the system renders the view. + // } + // } + EmptyView() } }