diff --git a/apps/ios-github-mobile/Package.swift b/apps/ios-github-mobile/Package.swift new file mode 100644 index 000000000..5ae9f673a --- /dev/null +++ b/apps/ios-github-mobile/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "GitHubMobile", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library( + name: "GitHubMobile", + targets: ["GitHubMobile"] + ) + ], + targets: [ + .target( + name: "GitHubMobile", + path: "Sources" + ) + ] +) diff --git a/apps/ios-github-mobile/README.md b/apps/ios-github-mobile/README.md new file mode 100644 index 000000000..e98229a69 --- /dev/null +++ b/apps/ios-github-mobile/README.md @@ -0,0 +1,96 @@ +# GitHub Mobile - SwiftUI App + +A beautiful SwiftUI mobile app inspired by the Claude Code mobile experience. This app provides a clean interface for managing GitHub repositories and sending code requests through an AI assistant. + +## Features + +- **Session Management**: View and manage coding sessions organized by repository +- **Chat Interface**: Send messages and view AI responses with tool execution details +- **Repository Selection**: Browse and select from your GitHub repositories +- **Model Selection**: Choose between different AI models (Sonnet, Opus, Haiku) +- **Dark Theme**: Beautiful dark theme matching the Claude Code aesthetic +- **PR Creation**: Simulate creating pull requests from the app + +## Screenshots + +The app is inspired by the Claude Code mobile app UI: +- Session list with repository information +- Chat view with collapsible tool executions (Write, Bash, Read, etc.) +- Bottom input with model/repo selection chips + +## Requirements + +- iOS 17.0+ +- Xcode 15.0+ +- Swift 5.9+ + +## Project Structure + +``` +Sources/ +├── GitHubMobileApp.swift # App entry point +├── ContentView.swift # Main navigation container +├── Models/ +│ ├── Models.swift # Data models (Session, Message, Repository, etc.) +│ └── AppState.swift # Observable app state +├── Views/ +│ ├── SessionListView.swift # Session list screen +│ └── ChatView.swift # Chat/conversation screen +├── Components/ +│ ├── MessageInputView.swift # Message input component +│ ├── PickerSheets.swift # Model and repo picker sheets +│ └── StatusIndicator.swift # Status indicators and badges +├── Theme/ +│ └── AppTheme.swift # Colors, spacing, and styling +└── Assets.xcassets/ # App icons and colors +``` + +## Architecture + +The app uses modern SwiftUI patterns: +- **@Observable** macro for state management (iOS 17+) +- **NavigationStack** with typed navigation paths +- **Environment** for dependency injection +- **View modifiers** for reusable styling + +## Building + +### Using Xcode + +1. Open `Package.swift` in Xcode +2. Select your target device +3. Build and run (Cmd+R) + +### Using Swift Package Manager + +```bash +swift build +``` + +## Mock Data + +The app includes mock data for demonstration: +- Sample repositories (clawdbot, systemss, Awesome-Prompts, etc.) +- Sample sessions with various titles +- Mock tool executions (Write, Bash commands) + +## Customization + +### Colors + +Edit `Theme/AppTheme.swift` to customize: +- Background colors +- Accent colors (warm orange by default) +- Text colors +- Tool execution colors + +### Mock Data + +Edit `Models/Models.swift` to customize: +- `Repository.mockList` - Available repositories +- `Session.mockList` - Session list items +- `Session.mockActiveSession()` - Active session with tool calls + +## License + +Open source - feel free to use and modify. diff --git a/apps/ios-github-mobile/Sources/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios-github-mobile/Sources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..5ee974dd8 --- /dev/null +++ b/apps/ios-github-mobile/Sources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.350", + "green" : "0.550", + "red" : "0.850" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios-github-mobile/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios-github-mobile/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..9f750565c --- /dev/null +++ b/apps/ios-github-mobile/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "universal", + "platform" : "macos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios-github-mobile/Sources/Assets.xcassets/Contents.json b/apps/ios-github-mobile/Sources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/apps/ios-github-mobile/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios-github-mobile/Sources/Assets.xcassets/LaunchBackground.colorset/Contents.json b/apps/ios-github-mobile/Sources/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 000000000..cf1991f02 --- /dev/null +++ b/apps/ios-github-mobile/Sources/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.080", + "green" : "0.080", + "red" : "0.080" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios-github-mobile/Sources/Components/MessageInputView.swift b/apps/ios-github-mobile/Sources/Components/MessageInputView.swift new file mode 100644 index 000000000..d5c30eb6c --- /dev/null +++ b/apps/ios-github-mobile/Sources/Components/MessageInputView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct MessageInputView: View { + @Binding var text: String + let placeholder: String + let onSend: () -> Void + + @FocusState private var isFocused: Bool + + var body: some View { + HStack(alignment: .bottom, spacing: AppTheme.spacingSM) { + // Add attachment button + Button { + // Add attachment action + } label: { + Image(systemName: "plus") + .font(.title3) + .foregroundStyle(AppTheme.secondaryText) + .frame(width: 36, height: 36) + } + + // Text input + ZStack(alignment: .leading) { + if text.isEmpty { + Text(placeholder) + .foregroundStyle(AppTheme.tertiaryText) + .padding(.horizontal, AppTheme.spacingMD) + .padding(.vertical, AppTheme.spacingMD) + } + + TextEditor(text: $text) + .scrollContentBackground(.hidden) + .padding(.horizontal, AppTheme.spacingSM) + .padding(.vertical, AppTheme.spacingSM) + .frame(minHeight: 40, maxHeight: 120) + .focused($isFocused) + } + .background(AppTheme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusLG)) + + // Send button + Button(action: onSend) { + Image(systemName: "arrow.up") + .font(.body) + .fontWeight(.semibold) + .foregroundStyle(text.isEmpty ? AppTheme.tertiaryText : AppTheme.buttonPrimaryText) + .frame(width: 36, height: 36) + .background(text.isEmpty ? AppTheme.chipBackground : AppTheme.accent) + .clipShape(Circle()) + } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.horizontal, AppTheme.spacingMD) + } +} + +#Preview { + ZStack { + AppTheme.background.ignoresSafeArea() + VStack { + Spacer() + MessageInputView( + text: .constant(""), + placeholder: "Ask Claude to help with code...", + onSend: {} + ) + } + } + .preferredColorScheme(.dark) +} diff --git a/apps/ios-github-mobile/Sources/Components/PickerSheets.swift b/apps/ios-github-mobile/Sources/Components/PickerSheets.swift new file mode 100644 index 000000000..147b8b93c --- /dev/null +++ b/apps/ios-github-mobile/Sources/Components/PickerSheets.swift @@ -0,0 +1,155 @@ +import SwiftUI + +// MARK: - Model Picker Sheet + +struct ModelPickerSheet: View { + @Environment(\.dismiss) private var dismiss + @Binding var selectedModel: AIModel + + var body: some View { + NavigationStack { + ZStack { + AppTheme.background.ignoresSafeArea() + + List { + ForEach(AIModel.all) { model in + Button { + selectedModel = model + dismiss() + } label: { + HStack { + VStack(alignment: .leading, spacing: AppTheme.spacingXS) { + Text(model.displayName) + .font(.body) + .foregroundStyle(AppTheme.primaryText) + + Text(modelDescription(for: model)) + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + } + + Spacer() + + if model.id == selectedModel.id { + Image(systemName: "checkmark") + .foregroundStyle(AppTheme.accent) + } + } + .padding(.vertical, AppTheme.spacingXS) + } + } + .listRowBackground(AppTheme.cardBackground) + } + .scrollContentBackground(.hidden) + } + .navigationTitle("Select Model") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + .foregroundStyle(AppTheme.primaryText) + } + } + } + } + + private func modelDescription(for model: AIModel) -> String { + switch model.name { + case "sonnet": + return "Balanced performance and speed" + case "opus": + return "Most capable, complex tasks" + case "haiku": + return "Fast and efficient" + default: + return "" + } + } +} + +// MARK: - Repository Picker Sheet + +struct RepositoryPickerSheet: View { + @Environment(\.dismiss) private var dismiss + let repositories: [Repository] + @Binding var selectedRepository: Repository? + @State private var searchText = "" + + private var filteredRepositories: [Repository] { + if searchText.isEmpty { + return repositories + } + return repositories.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + $0.owner.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + NavigationStack { + ZStack { + AppTheme.background.ignoresSafeArea() + + List { + ForEach(filteredRepositories) { repo in + Button { + selectedRepository = repo + dismiss() + } label: { + HStack { + Image(systemName: "folder") + .foregroundStyle(AppTheme.secondaryText) + + VStack(alignment: .leading, spacing: AppTheme.spacingXS) { + Text(repo.name) + .font(.body) + .foregroundStyle(AppTheme.primaryText) + + Text(repo.owner) + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + } + + Spacer() + + if repo.id == selectedRepository?.id { + Image(systemName: "checkmark") + .foregroundStyle(AppTheme.accent) + } + } + .padding(.vertical, AppTheme.spacingXS) + } + } + .listRowBackground(AppTheme.cardBackground) + } + .scrollContentBackground(.hidden) + .searchable(text: $searchText, prompt: "Search repositories") + } + .navigationTitle("Select Repository") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + .foregroundStyle(AppTheme.primaryText) + } + } + } + } +} + +#Preview("Model Picker") { + ModelPickerSheet(selectedModel: .constant(.sonnet)) + .preferredColorScheme(.dark) +} + +#Preview("Repository Picker") { + RepositoryPickerSheet( + repositories: Repository.mockList, + selectedRepository: .constant(nil) + ) + .preferredColorScheme(.dark) +} diff --git a/apps/ios-github-mobile/Sources/Components/StatusIndicator.swift b/apps/ios-github-mobile/Sources/Components/StatusIndicator.swift new file mode 100644 index 000000000..6d10dffac --- /dev/null +++ b/apps/ios-github-mobile/Sources/Components/StatusIndicator.swift @@ -0,0 +1,108 @@ +import SwiftUI + +struct StatusIndicator: View { + let status: SessionStatus + + var body: some View { + HStack(spacing: AppTheme.spacingXS) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + + Text(status.rawValue) + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + } + } + + private var statusColor: Color { + switch status { + case .idle: + return AppTheme.secondaryText + case .running: + return AppTheme.accent + case .completed: + return AppTheme.success + case .error: + return AppTheme.error + } + } +} + +// MARK: - Loading Indicator + +struct LoadingIndicator: View { + @State private var isAnimating = false + + var body: some View { + HStack(spacing: AppTheme.spacingXS) { + ForEach(0..<3) { index in + Circle() + .fill(AppTheme.accent) + .frame(width: 6, height: 6) + .scaleEffect(isAnimating ? 1.0 : 0.5) + .animation( + .easeInOut(duration: 0.6) + .repeatForever() + .delay(Double(index) * 0.2), + value: isAnimating + ) + } + } + .onAppear { + isAnimating = true + } + } +} + +// MARK: - Tool Status Badge + +struct ToolStatusBadge: View { + let status: ToolCallStatus + + var body: some View { + HStack(spacing: AppTheme.spacingXS) { + switch status { + case .pending: + Image(systemName: "clock") + .foregroundStyle(AppTheme.secondaryText) + case .running: + ProgressView() + .scaleEffect(0.7) + .tint(AppTheme.accent) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(AppTheme.success) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(AppTheme.error) + } + } + .font(.caption) + } +} + +#Preview { + VStack(spacing: 20) { + StatusIndicator(status: .idle) + StatusIndicator(status: .running) + StatusIndicator(status: .completed) + StatusIndicator(status: .error) + + Divider() + + LoadingIndicator() + + Divider() + + HStack(spacing: 20) { + ToolStatusBadge(status: .pending) + ToolStatusBadge(status: .running) + ToolStatusBadge(status: .completed) + ToolStatusBadge(status: .failed) + } + } + .padding() + .background(AppTheme.background) + .preferredColorScheme(.dark) +} diff --git a/apps/ios-github-mobile/Sources/ContentView.swift b/apps/ios-github-mobile/Sources/ContentView.swift new file mode 100644 index 000000000..17bc051df --- /dev/null +++ b/apps/ios-github-mobile/Sources/ContentView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct ContentView: View { + @Environment(AppState.self) private var appState + + var body: some View { + NavigationStack(path: Bindable(appState).navigationPath) { + SessionListView() + .navigationDestination(for: Session.self) { session in + ChatView(session: session) + } + } + } +} + +#Preview { + ContentView() + .environment(AppState()) + .preferredColorScheme(.dark) +} diff --git a/apps/ios-github-mobile/Sources/GitHubMobileApp.swift b/apps/ios-github-mobile/Sources/GitHubMobileApp.swift new file mode 100644 index 000000000..27e5b3fb9 --- /dev/null +++ b/apps/ios-github-mobile/Sources/GitHubMobileApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct GitHubMobileApp: App { + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(appState) + .preferredColorScheme(.dark) + } + } +} diff --git a/apps/ios-github-mobile/Sources/Info.plist b/apps/ios-github-mobile/Sources/Info.plist new file mode 100644 index 000000000..7c70aae45 --- /dev/null +++ b/apps/ios-github-mobile/Sources/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Code + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + LaunchBackground + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + + diff --git a/apps/ios-github-mobile/Sources/Models/AppState.swift b/apps/ios-github-mobile/Sources/Models/AppState.swift new file mode 100644 index 000000000..6a5bb19ca --- /dev/null +++ b/apps/ios-github-mobile/Sources/Models/AppState.swift @@ -0,0 +1,92 @@ +import Foundation +import Observation +import SwiftUI + +@Observable +@MainActor +final class AppState { + var sessions: [Session] = Session.mockList + var repositories: [Repository] = Repository.mockList + var selectedModel: AIModel = .sonnet + var currentSession: Session? + var navigationPath = NavigationPath() + + // Current input state + var messageInput: String = "" + var selectedRepository: Repository? + var selectedBranch: String = "main" + + init() { + self.selectedRepository = repositories.first + } + + func createNewSession(title: String, repository: Repository) -> Session { + let session = Session( + id: UUID().uuidString, + title: title, + repository: repository, + createdAt: Date(), + status: .idle, + messages: [] + ) + sessions.insert(session, at: 0) + return session + } + + func sendMessage(_ content: String, to session: Session) { + guard let index = sessions.firstIndex(where: { $0.id == session.id }) else { return } + + let message = Message( + id: UUID().uuidString, + role: .user, + content: content, + timestamp: Date(), + toolCalls: [] + ) + + sessions[index].messages.append(message) + sessions[index].status = .running + + // Simulate assistant response after a delay + simulateResponse(for: sessions[index]) + } + + private func simulateResponse(for session: Session) { + guard let index = sessions.firstIndex(where: { $0.id == session.id }) else { return } + + // Create a simulated assistant response + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + + let assistantMessage = Message( + id: UUID().uuidString, + role: .assistant, + content: "Anladım! Şimdi bu değişiklikleri yapmaya başlıyorum...", + timestamp: Date(), + toolCalls: [ + ToolCall( + id: UUID().uuidString, + type: .read, + name: "Read", + input: "/home/user/\(session.repository.name)/README.md", + output: "# \(session.repository.name)\n\nProject description...", + status: .completed + ) + ] + ) + + self.sessions[index].messages.append(assistantMessage) + self.sessions[index].status = .idle + } + } + + func openSession(_ session: Session) { + currentSession = session + navigationPath.append(session) + } + + func selectRepository(_ repository: Repository) { + selectedRepository = repository + selectedBranch = repository.defaultBranch + } +} diff --git a/apps/ios-github-mobile/Sources/Models/Models.swift b/apps/ios-github-mobile/Sources/Models/Models.swift new file mode 100644 index 000000000..6b02adbbb --- /dev/null +++ b/apps/ios-github-mobile/Sources/Models/Models.swift @@ -0,0 +1,277 @@ +import Foundation + +// MARK: - Repository + +struct Repository: Identifiable, Hashable, Codable { + let id: String + let name: String + let owner: String + let defaultBranch: String + + var fullName: String { + "\(owner)/\(name)" + } + + static let mock = Repository( + id: "1", + name: "clawdbot", + owner: "semihpolat", + defaultBranch: "main" + ) + + static let mockList: [Repository] = [ + Repository(id: "1", name: "clawdbot", owner: "semihpolat", defaultBranch: "main"), + Repository(id: "2", name: "systemss", owner: "semihpolat", defaultBranch: "main"), + Repository(id: "3", name: "Awesome-Prompts", owner: "semihpolat", defaultBranch: "main"), + Repository(id: "4", name: "statue", owner: "semihpolat", defaultBranch: "main"), + Repository(id: "5", name: "littleagents", owner: "semihpolat", defaultBranch: "main"), + Repository(id: "6", name: "ultimateaiscraper", owner: "semihpolat", defaultBranch: "main"), + ] +} + +// MARK: - Session + +struct Session: Identifiable, Hashable { + let id: String + let title: String + let repository: Repository + let createdAt: Date + var status: SessionStatus + var messages: [Message] + + static func == (lhs: Session, rhs: Session) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +enum SessionStatus: String, Codable { + case idle = "Idle" + case running = "Running" + case completed = "Completed" + case error = "Error" +} + +// MARK: - Message + +struct Message: Identifiable, Hashable { + let id: String + let role: MessageRole + let content: String + let timestamp: Date + var toolCalls: [ToolCall] + + static func == (lhs: Message, rhs: Message) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +enum MessageRole: String, Codable { + case user + case assistant + case system +} + +// MARK: - Tool Call + +struct ToolCall: Identifiable, Hashable { + let id: String + let type: ToolType + let name: String + let input: String + var output: String? + var status: ToolCallStatus + + static func == (lhs: ToolCall, rhs: ToolCall) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +enum ToolType: String, Codable { + case write = "Write" + case bash = "Bash" + case read = "Read" + case edit = "Edit" + case glob = "Glob" + case grep = "Grep" + + var color: SwiftUI.Color { + switch self { + case .write, .edit: + return AppTheme.toolWrite + case .bash: + return AppTheme.toolBash + case .read, .glob, .grep: + return AppTheme.toolRead + } + } +} + +enum ToolCallStatus: String, Codable { + case pending + case running + case completed + case failed +} + +// MARK: - AI Model + +struct AIModel: Identifiable, Hashable { + let id: String + let name: String + let displayName: String + + static let sonnet = AIModel(id: "sonnet-4.5", name: "sonnet", displayName: "Sonnet 4.5") + static let opus = AIModel(id: "opus-4.5", name: "opus", displayName: "Opus 4.5") + static let haiku = AIModel(id: "haiku", name: "haiku", displayName: "Haiku") + + static let all: [AIModel] = [.sonnet, .opus, .haiku] +} + +// MARK: - Mock Data + +extension Session { + static let mockList: [Session] = [ + Session( + id: "1", + title: "Design Claude Code system architecture for mobile", + repository: Repository.mockList[1], + createdAt: Date().addingTimeInterval(-3600), + status: .idle, + messages: [] + ), + Session( + id: "2", + title: "Add popular tweets with images to platform showcase", + repository: Repository.mockList[2], + createdAt: Date().addingTimeInterval(-7200), + status: .idle, + messages: [] + ), + Session( + id: "3", + title: "Add popular tweets with images to platform library", + repository: Repository.mockList[2], + createdAt: Date().addingTimeInterval(-10800), + status: .idle, + messages: [] + ), + Session( + id: "4", + title: "Evaluate migrating SvelteKit project to Next.js", + repository: Repository.mockList[3], + createdAt: Date().addingTimeInterval(-14400), + status: .idle, + messages: [] + ), + Session( + id: "5", + title: "Update resources list with current tools and links", + repository: Repository.mockList[4], + createdAt: Date().addingTimeInterval(-18000), + status: .idle, + messages: [] + ), + Session( + id: "6", + title: "Sync fork with original repository", + repository: Repository.mockList[3], + createdAt: Date().addingTimeInterval(-21600), + status: .idle, + messages: [] + ), + Session( + id: "7", + title: "Improve UI Design with Shadcn Style", + repository: Repository.mockList[5], + createdAt: Date().addingTimeInterval(-25200), + status: .idle, + messages: [] + ), + ] + + static func mockActiveSession() -> Session { + Session( + id: "active-1", + title: "Add popular tweets with images to platform", + repository: Repository.mockList[2], + createdAt: Date().addingTimeInterval(-1800), + status: .running, + messages: [ + Message( + id: "m1", + role: .assistant, + content: "Haklisın, hemen README.md'yi oluşturup push yapıyorum!", + timestamp: Date().addingTimeInterval(-1200), + toolCalls: [ + ToolCall( + id: "tc1", + type: .write, + name: "Write", + input: "/home/user/Awesome-Prompts/README.md", + output: """ + + # Awesome AI Prompts + + + + A curated collection of powerful AI prompts that went viral on X/Twitter. + + These prompts have been battle-tested by thousands of users and proven to deliver + + exceptional results with ChatGPT, Claude, and other LLMs. + + + """, + status: .completed + ) + ] + ), + Message( + id: "m2", + role: .assistant, + content: "Şimdi commit ve push yapıyorum:", + timestamp: Date().addingTimeInterval(-900), + toolCalls: [ + ToolCall( + id: "tc2", + type: .bash, + name: "Bash", + input: "git add README.md && git status", + output: nil, + status: .completed + ), + ToolCall( + id: "tc3", + type: .bash, + name: "Bash", + input: "git commit -m \"$(cat <<'EOF'\nAdd comprehensive Awesome AI Prompts collection...\nEOF\n)\"", + output: nil, + status: .completed + ), + ToolCall( + id: "tc4", + type: .bash, + name: "Bash", + input: "git push -u origin claude/add-popular-tweets-dy04J", + output: nil, + status: .completed + ) + ] + ), + Message( + id: "m3", + role: .assistant, + content: "Push tamam! Şimdi PR oluşturuyorum:", + timestamp: Date().addingTimeInterval(-600), + toolCalls: [] + ), + ] + ) + } +} diff --git a/apps/ios-github-mobile/Sources/Theme/AppTheme.swift b/apps/ios-github-mobile/Sources/Theme/AppTheme.swift new file mode 100644 index 000000000..2dfa56595 --- /dev/null +++ b/apps/ios-github-mobile/Sources/Theme/AppTheme.swift @@ -0,0 +1,86 @@ +import SwiftUI + +enum AppTheme { + // Background colors + static let background = Color(red: 0.08, green: 0.08, blue: 0.08) + static let surfaceBackground = Color(red: 0.12, green: 0.12, blue: 0.12) + static let cardBackground = Color(red: 0.16, green: 0.16, blue: 0.16) + static let inputBackground = Color(red: 0.14, green: 0.14, blue: 0.14) + + // Text colors + static let primaryText = Color.white + static let secondaryText = Color(white: 0.6) + static let tertiaryText = Color(white: 0.45) + + // Accent colors + static let accent = Color(red: 0.85, green: 0.55, blue: 0.35) // Warm orange like Claude + static let accentLight = Color(red: 0.95, green: 0.75, blue: 0.55) + + // Status colors + static let success = Color(red: 0.3, green: 0.7, blue: 0.4) + static let warning = Color(red: 0.9, green: 0.7, blue: 0.3) + static let error = Color(red: 0.9, green: 0.4, blue: 0.4) + + // Tool colors + static let toolWrite = Color(red: 0.4, green: 0.7, blue: 0.4) + static let toolBash = Color(red: 0.5, green: 0.6, blue: 0.8) + static let toolRead = Color(red: 0.7, green: 0.6, blue: 0.8) + + // Chip/Tag colors + static let chipBackground = Color(red: 0.2, green: 0.2, blue: 0.2) + static let chipBorder = Color(white: 0.3) + + // Button colors + static let buttonPrimary = Color(white: 0.95) + static let buttonPrimaryText = Color.black + + // Spacing + static let spacingXS: CGFloat = 4 + static let spacingSM: CGFloat = 8 + static let spacingMD: CGFloat = 12 + static let spacingLG: CGFloat = 16 + static let spacingXL: CGFloat = 24 + + // Corner radius + static let radiusSM: CGFloat = 6 + static let radiusMD: CGFloat = 10 + static let radiusLG: CGFloat = 16 + static let radiusXL: CGFloat = 24 + static let radiusFull: CGFloat = 100 +} + +// MARK: - View Modifiers + +struct CardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .background(AppTheme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusLG)) + } +} + +struct ChipStyle: ViewModifier { + func body(content: Content) -> some View { + content + .font(.subheadline) + .foregroundStyle(AppTheme.primaryText) + .padding(.horizontal, AppTheme.spacingMD) + .padding(.vertical, AppTheme.spacingSM) + .background(AppTheme.chipBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusFull)) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.radiusFull) + .stroke(AppTheme.chipBorder, lineWidth: 1) + ) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardStyle()) + } + + func chipStyle() -> some View { + modifier(ChipStyle()) + } +} diff --git a/apps/ios-github-mobile/Sources/Views/ChatView.swift b/apps/ios-github-mobile/Sources/Views/ChatView.swift new file mode 100644 index 000000000..7d6b2b4fe --- /dev/null +++ b/apps/ios-github-mobile/Sources/Views/ChatView.swift @@ -0,0 +1,341 @@ +import SwiftUI + +struct ChatView: View { + @Environment(AppState.self) private var appState + let session: Session + @State private var messageText = "" + @State private var showModelPicker = false + @State private var selectedModel: AIModel = .sonnet + @State private var showCreatePR = false + + private var currentSession: Session { + appState.sessions.first { $0.id == session.id } ?? session + } + + var body: some View { + ZStack { + AppTheme.background.ignoresSafeArea() + + VStack(spacing: 0) { + // Messages scroll view + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: AppTheme.spacingLG) { + ForEach(currentSession.messages) { message in + MessageView(message: message) + .id(message.id) + } + + // Create PR button if there are tool calls + if currentSession.messages.contains(where: { !$0.toolCalls.isEmpty }) { + CreatePRButton( + branchName: "claude/add-popular-tweets-dy04J", + showCreatePR: $showCreatePR + ) + .padding(.horizontal, AppTheme.spacingLG) + } + } + .padding(.vertical, AppTheme.spacingMD) + } + .onChange(of: currentSession.messages.count) { _, _ in + if let lastMessage = currentSession.messages.last { + withAnimation { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + } + + // Input area + VStack(spacing: AppTheme.spacingMD) { + MessageInputView( + text: $messageText, + placeholder: "Add feedback...", + onSend: sendMessage + ) + + // Bottom chips + HStack(spacing: AppTheme.spacingSM) { + // Branch indicator + HStack(spacing: AppTheme.spacingXS) { + Text("Branch") + .foregroundStyle(AppTheme.secondaryText) + Text("claude/add-popular-tweets...") + .foregroundStyle(AppTheme.primaryText) + } + .font(.caption) + .padding(.horizontal, AppTheme.spacingMD) + .padding(.vertical, AppTheme.spacingSM) + .background(AppTheme.chipBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD)) + + // Create PR button inline + Button { + showCreatePR = true + } label: { + HStack(spacing: AppTheme.spacingXS) { + Text("Create PR") + .foregroundStyle(AppTheme.primaryText) + Image(systemName: "arrow.up.right") + .font(.caption) + Image(systemName: "ellipsis") + .font(.caption2) + } + } + .font(.caption) + .padding(.horizontal, AppTheme.spacingMD) + .padding(.vertical, AppTheme.spacingSM) + .background(AppTheme.chipBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD)) + + Spacer() + } + .padding(.horizontal, AppTheme.spacingMD) + } + .padding(.bottom, AppTheme.spacingMD) + .background(AppTheme.background) + } + } + .navigationTitle(session.repository.fullName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + VStack(spacing: 2) { + Text(String(session.title.prefix(30)) + (session.title.count > 30 ? "..." : "")) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(AppTheme.primaryText) + Text("\(session.repository.fullName) · Default") + .font(.caption2) + .foregroundStyle(AppTheme.secondaryText) + } + } + + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("View on GitHub") {} + Button("Copy branch name") {} + Divider() + Button("Delete session", role: .destructive) {} + } label: { + Image(systemName: "ellipsis.circle") + .foregroundStyle(AppTheme.primaryText) + } + } + } + .sheet(isPresented: $showCreatePR) { + CreatePRSheet(session: currentSession) + .presentationDetents([.medium]) + } + } + + private func sendMessage() { + guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + appState.sendMessage(messageText, to: currentSession) + messageText = "" + } +} + +// MARK: - Message View + +struct MessageView: View { + let message: Message + @State private var expandedToolCalls: Set = [] + + var body: some View { + VStack(alignment: .leading, spacing: AppTheme.spacingMD) { + // Message content + if !message.content.isEmpty { + Text(message.content) + .font(.body) + .foregroundStyle(AppTheme.primaryText) + .padding(.horizontal, AppTheme.spacingLG) + } + + // Tool calls + ForEach(message.toolCalls) { toolCall in + ToolCallView( + toolCall: toolCall, + isExpanded: expandedToolCalls.contains(toolCall.id), + onToggle: { + if expandedToolCalls.contains(toolCall.id) { + expandedToolCalls.remove(toolCall.id) + } else { + expandedToolCalls.insert(toolCall.id) + } + } + ) + .padding(.horizontal, AppTheme.spacingLG) + } + } + } +} + +// MARK: - Tool Call View + +struct ToolCallView: View { + let toolCall: ToolCall + let isExpanded: Bool + let onToggle: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + Button(action: onToggle) { + HStack(spacing: AppTheme.spacingSM) { + // Tool type badge + Text(toolCall.type.rawValue) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(toolCall.type.color) + + // File path or command + Text(toolCall.input) + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + .lineLimit(1) + + Spacer() + + // Expand indicator + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(AppTheme.tertiaryText) + } + .padding(.horizontal, AppTheme.spacingMD) + .padding(.vertical, AppTheme.spacingSM) + } + .buttonStyle(.plain) + + // Expanded content + if isExpanded, let output = toolCall.output { + ScrollView(.horizontal, showsIndicators: false) { + Text(output) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(AppTheme.secondaryText) + .padding(AppTheme.spacingMD) + } + .frame(maxHeight: 200) + .background(AppTheme.surfaceBackground) + } + } + .background(AppTheme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD)) + } +} + +// MARK: - Create PR Button + +struct CreatePRButton: View { + let branchName: String + @Binding var showCreatePR: Bool + + var body: some View { + HStack { + Text(branchName) + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + .lineLimit(1) + + Spacer() + + Button { + showCreatePR = true + } label: { + HStack(spacing: AppTheme.spacingXS) { + Text("Create PR") + .fontWeight(.medium) + Image(systemName: "arrow.up.right") + Image(systemName: "ellipsis") + } + .font(.subheadline) + .foregroundStyle(AppTheme.primaryText) + } + } + .padding(AppTheme.spacingMD) + .background(AppTheme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD)) + } +} + +// MARK: - Create PR Sheet + +struct CreatePRSheet: View { + @Environment(\.dismiss) private var dismiss + let session: Session + @State private var prTitle = "" + @State private var prDescription = "" + + var body: some View { + NavigationStack { + ZStack { + AppTheme.background.ignoresSafeArea() + + VStack(spacing: AppTheme.spacingLG) { + VStack(alignment: .leading, spacing: AppTheme.spacingSM) { + Text("Title") + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + + TextField("PR title", text: $prTitle) + .textFieldStyle(.plain) + .padding(AppTheme.spacingMD) + .background(AppTheme.inputBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD)) + } + + VStack(alignment: .leading, spacing: AppTheme.spacingSM) { + Text("Description") + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + + TextEditor(text: $prDescription) + .scrollContentBackground(.hidden) + .padding(AppTheme.spacingMD) + .background(AppTheme.inputBackground) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusMD)) + .frame(minHeight: 120) + } + + Spacer() + + Button { + // Create PR action + dismiss() + } label: { + Text("Create Pull Request") + .font(.body) + .fontWeight(.medium) + .foregroundStyle(AppTheme.buttonPrimaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, AppTheme.spacingMD) + .background(AppTheme.buttonPrimary) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusFull)) + } + } + .padding(AppTheme.spacingLG) + } + .navigationTitle("Create PR") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundStyle(AppTheme.primaryText) + } + } + .onAppear { + prTitle = session.title + } + } + } +} + +#Preview { + NavigationStack { + ChatView(session: Session.mockActiveSession()) + } + .environment(AppState()) + .preferredColorScheme(.dark) +} diff --git a/apps/ios-github-mobile/Sources/Views/SessionListView.swift b/apps/ios-github-mobile/Sources/Views/SessionListView.swift new file mode 100644 index 000000000..229ab0575 --- /dev/null +++ b/apps/ios-github-mobile/Sources/Views/SessionListView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +struct SessionListView: View { + @Environment(AppState.self) private var appState + @State private var showingNewSessionSheet = false + + var body: some View { + ZStack { + AppTheme.background.ignoresSafeArea() + + VStack(spacing: 0) { + // Session list + ScrollView { + LazyVStack(spacing: 0) { + // Status indicator + HStack { + Text("Idle") + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + Spacer() + } + .padding(.horizontal, AppTheme.spacingLG) + .padding(.top, AppTheme.spacingMD) + .padding(.bottom, AppTheme.spacingSM) + + // Sessions + ForEach(appState.sessions) { session in + SessionRowView(session: session) + .onTapGesture { + appState.openSession(session) + } + } + } + } + + // New session button + Button { + showingNewSessionSheet = true + } label: { + Text("New session") + .font(.body) + .fontWeight(.medium) + .foregroundStyle(AppTheme.buttonPrimaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, AppTheme.spacingMD) + .background(AppTheme.buttonPrimary) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radiusFull)) + } + .padding(.horizontal, AppTheme.spacingLG) + .padding(.vertical, AppTheme.spacingMD) + .background(AppTheme.background) + } + } + .navigationTitle("Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + // Settings/filter menu + } label: { + Image(systemName: "line.3.horizontal.decrease") + .foregroundStyle(AppTheme.primaryText) + } + } + } + .sheet(isPresented: $showingNewSessionSheet) { + NewSessionSheet() + } + } +} + +// MARK: - Session Row + +struct SessionRowView: View { + let session: Session + + var body: some View { + HStack(alignment: .center, spacing: AppTheme.spacingMD) { + VStack(alignment: .leading, spacing: AppTheme.spacingXS) { + Text(session.title) + .font(.body) + .foregroundStyle(AppTheme.primaryText) + .lineLimit(2) + + Text(session.repository.fullName) + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(AppTheme.tertiaryText) + } + .padding(.horizontal, AppTheme.spacingLG) + .padding(.vertical, AppTheme.spacingMD) + .contentShape(Rectangle()) + } +} + +// MARK: - New Session Sheet + +struct NewSessionSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var messageText = "" + @State private var selectedRepository: Repository? + @State private var selectedBranch = "main" + @State private var selectedModel: AIModel = .sonnet + @State private var showModelPicker = false + @State private var showRepoPicker = false + + var body: some View { + NavigationStack { + ZStack { + AppTheme.background.ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + // Message input area + VStack(spacing: AppTheme.spacingMD) { + // Text input + MessageInputView( + text: $messageText, + placeholder: "Ask Claude to help with code...", + onSend: createSession + ) + + // Bottom chips + HStack(spacing: AppTheme.spacingSM) { + // Model picker + Button { + showModelPicker = true + } label: { + HStack(spacing: AppTheme.spacingXS) { + Image(systemName: "cube") + Text(selectedModel.displayName) + Image(systemName: "chevron.down") + .font(.caption2) + } + } + .chipStyle() + + // Repository picker + Button { + showRepoPicker = true + } label: { + HStack(spacing: AppTheme.spacingXS) { + Image(systemName: "folder") + Text(selectedRepository?.name ?? "Select repo") + } + } + .chipStyle() + + // Branch + if selectedRepository != nil { + HStack(spacing: AppTheme.spacingXS) { + Image(systemName: "arrow.triangle.branch") + Text(selectedBranch) + } + .chipStyle() + } + + Spacer() + } + .padding(.horizontal, AppTheme.spacingMD) + } + .padding(.bottom, AppTheme.spacingLG) + } + } + .navigationTitle("New Session") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundStyle(AppTheme.primaryText) + } + } + .onAppear { + selectedRepository = appState.repositories.first + } + .sheet(isPresented: $showModelPicker) { + ModelPickerSheet(selectedModel: $selectedModel) + .presentationDetents([.medium]) + } + .sheet(isPresented: $showRepoPicker) { + RepositoryPickerSheet( + repositories: appState.repositories, + selectedRepository: $selectedRepository + ) + .presentationDetents([.medium, .large]) + } + } + } + + private func createSession() { + guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let repository = selectedRepository else { return } + + let session = appState.createNewSession( + title: String(messageText.prefix(50)), + repository: repository + ) + appState.sendMessage(messageText, to: session) + dismiss() + appState.openSession(session) + } +} + +#Preview { + NavigationStack { + SessionListView() + } + .environment(AppState()) + .preferredColorScheme(.dark) +}