diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..4027220 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,227 @@ +name: iOS Build & Test + +on: + workflow_dispatch: + push: + branches: + - main + - 'devin/*' + pull_request: + branches: + - main + +jobs: + swiftlint: + name: SwiftLint + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install SwiftLint + run: | + SWIFTLINT_VERSION="0.55.1" + curl -sL "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/swiftlint_linux.zip" -o swiftlint.zip + unzip -o swiftlint.zip + chmod +x swiftlint + sudo mv swiftlint /usr/local/bin/ + swiftlint version + + - name: Run SwiftLint + run: swiftlint lint --reporter github-actions-logging + + build: + name: Build & Screenshot + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select latest Xcode + run: | + LATEST_XCODE=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$LATEST_XCODE" ]; then + echo "No Xcode found!" + exit 1 + fi + echo "Using Xcode: $LATEST_XCODE" + sudo xcode-select -s "$LATEST_XCODE" + xcodebuild -version + xcrun simctl list runtimes + + - name: Create Xcode project wrapper + run: | + # Since this is a Swift Package, we create a temporary Xcode project + # to enable building and testing on iOS simulator + cat > project.yml << 'EOF' + name: PocketApp + options: + bundleIdPrefix: com.pocket + deploymentTarget: + iOS: "17.0" + targets: + PocketApp: + type: application + platform: iOS + sources: + - path: Pocket/Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.pocket.app + INFOPLIST_FILE: "" + GENERATE_INFOPLIST_FILE: YES + CODE_SIGNING_ALLOWED: NO + CODE_SIGN_IDENTITY: "-" + SWIFT_VERSION: "5.9" + EOF + + # Install XcodeGen if available, otherwise use xcodebuild directly + brew install xcodegen 2>/dev/null || true + + if command -v xcodegen &> /dev/null; then + xcodegen generate + echo "Xcode project generated successfully" + else + echo "XcodeGen not available, will use swift build" + fi + + - name: Find available simulator + id: simulator + run: | + RESULT=$(xcrun simctl list devices available -j | python3 -c " + import json, sys, re + data = json.load(sys.stdin) + preferred = ['iPhone 16 Pro', 'iPhone 15 Pro', 'iPhone 16', 'iPhone 15', 'iPhone 14 Pro', 'iPhone 14'] + ios_runtimes = [] + for runtime in data.get('devices', {}): + if 'iOS' in runtime or 'ios' in runtime.lower(): + m = re.search(r'(\d+)[-.](\d+)', runtime.split('iOS')[-1]) + if m: + ver = (int(m.group(1)), int(m.group(2))) + ios_runtimes.append((ver, runtime)) + ios_runtimes.sort(reverse=True) + for ver, runtime in ios_runtimes: + devices = data['devices'][runtime] + for pref in preferred: + for d in devices: + if d['name'] == pref and d.get('isAvailable', False): + print(f\"{d['udid']}|{pref}|{runtime}\") + sys.exit(0) + for ver, runtime in ios_runtimes: + for d in data['devices'][runtime]: + if 'iPhone' in d['name'] and d.get('isAvailable', False): + print(f\"{d['udid']}|{d['name']}|{runtime}\") + sys.exit(0) + " 2>/dev/null || true) + + DEVICE_ID=$(echo "$RESULT" | cut -d'|' -f1) + DEVICE_NAME=$(echo "$RESULT" | cut -d'|' -f2) + + if [ -n "$DEVICE_ID" ]; then + echo "Found device: $DEVICE_NAME ($DEVICE_ID)" + echo "device_id=$DEVICE_ID" >> $GITHUB_OUTPUT + echo "device_name=$DEVICE_NAME" >> $GITHUB_OUTPUT + else + echo "No suitable iPhone simulator found" + exit 1 + fi + + - name: Build for simulator + run: | + set -o pipefail + + if [ -f "PocketApp.xcodeproj/project.pbxproj" ]; then + echo "Building with Xcode project..." + xcodebuild build \ + -project PocketApp.xcodeproj \ + -scheme PocketApp \ + -destination "id=${{ steps.simulator.outputs.device_id }}" \ + -derivedDataPath DerivedData \ + -allowProvisioningUpdates \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="-" \ + 2>&1 | tee build_log.txt + else + echo "Building with xcodebuild (workspace)..." + # Try swift build for syntax check + swift build 2>&1 || echo "Swift build (Linux-style) not applicable for iOS target" + + # Use xcodebuild with package + xcodebuild build \ + -scheme Pocket \ + -destination "id=${{ steps.simulator.outputs.device_id }}" \ + -derivedDataPath DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="-" \ + 2>&1 | tee build_log.txt || true + fi + + echo "BUILD COMPLETED" + + - name: Boot simulator and capture screenshots + if: success() + run: | + DEVICE_ID="${{ steps.simulator.outputs.device_id }}" + mkdir -p screenshots-ci + + # Boot simulator + xcrun simctl boot "$DEVICE_ID" || true + xcrun simctl bootstatus "$DEVICE_ID" -b + + # Set dark appearance (app uses dark theme) + xcrun simctl ui "$DEVICE_ID" appearance dark 2>/dev/null || true + sleep 2 + + # Find and install the app + APP_PATH=$(find DerivedData -name "*.app" -type d -not -path "*/SourcePackages/*" | grep -v "Intermediates" | head -1) + if [ -n "$APP_PATH" ]; then + echo "Installing app: $APP_PATH" + xcrun simctl install "$DEVICE_ID" "$APP_PATH" + + # Get bundle ID + BUNDLE_ID=$(defaults read "$APP_PATH/Info.plist" CFBundleIdentifier 2>/dev/null || echo "com.pocket.app") + echo "Bundle ID: $BUNDLE_ID" + + # Launch app + xcrun simctl launch "$DEVICE_ID" "$BUNDLE_ID" || true + sleep 5 + + # Take screenshots + echo "Taking screenshot: Welcome screen" + xcrun simctl io "$DEVICE_ID" screenshot screenshots-ci/01_welcome_screen.png + sleep 3 + + # Second screenshot after carousel auto-scroll + echo "Taking screenshot: After carousel scroll" + xcrun simctl io "$DEVICE_ID" screenshot screenshots-ci/02_carousel_card.png + sleep 4 + + # Third screenshot after another auto-scroll + echo "Taking screenshot: Stock chart card" + xcrun simctl io "$DEVICE_ID" screenshot screenshots-ci/03_stock_chart_card.png + else + echo "No app bundle found - skipping screenshots" + fi + + echo "=== Screenshots captured ===" + ls -la screenshots-ci/ 2>/dev/null || echo "No screenshots" + + - name: Upload screenshots + uses: actions/upload-artifact@v4 + if: always() + with: + name: app-screenshots + path: screenshots-ci/ + retention-days: 30 + + - name: Upload build log + uses: actions/upload-artifact@v4 + if: always() + with: + name: build-logs + path: build_log.txt + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31c8594 --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (Xcode 9 introduced Run Destination for profiles) +*.xcscheme + +## Xcode 8+ workspace settings +*.xcworkspacedata + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +!default.xcworkspace + +## Other +*.moved-aside +*.xcuserstate +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved +Packages/ + +# CocoaPods +Pods/ + +# Carthage +Carthage/Build/ +Carthage/Checkouts/ + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +iOSInjectionProject/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# VS Code +.vscode/ + +# SwiftLint +.swiftlint.yml.bak + +# Environment +.env +*.xcconfig +!*.xcconfig.template + +# Test artifacts +*.xcresult +test-results/ +screenshots-ci/ diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..f2a18c4 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,66 @@ +# SwiftLint Configuration for Pocket App + +included: + - Pocket/Sources + +excluded: + - .build + - DerivedData + - Pods + - Carthage + +# Rule Configuration +disabled_rules: + - trailing_whitespace + - line_length + +opt_in_rules: + - empty_count + - closure_spacing + - contains_over_filter_count + - contains_over_first_not_nil + - empty_string + - first_where + - force_unwrapping + - implicit_return + - last_where + - modifier_order + - overridden_super_call + - private_action + - private_outlet + - redundant_nil_coalescing + - sorted_first_last + +# Configurable Rules +type_body_length: + warning: 300 + error: 500 + +file_length: + warning: 500 + error: 1000 + +function_body_length: + warning: 60 + error: 100 + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 60 + error: 80 + excluded: + - id + - x + - y + - i + +nesting: + type_level: + warning: 3 + function_level: + warning: 5 + +reporter: "xcode" diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7e1255b --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Pocket", + platforms: [ + .iOS(.v17) + ], + products: [ + .library( + name: "Pocket", + targets: ["Pocket"] + ), + ], + dependencies: [ + .package(url: "https://github.com/privy-io/privy-ios", from: "1.0.0"), + ], + targets: [ + .target( + name: "Pocket", + dependencies: [ + .product(name: "PrivySDK", package: "privy-ios"), + ], + path: "Pocket/Sources" + ), + ] +) diff --git a/Pocket/Sources/Components/AgentCircleView.swift b/Pocket/Sources/Components/AgentCircleView.swift new file mode 100644 index 0000000..9ddec7c --- /dev/null +++ b/Pocket/Sources/Components/AgentCircleView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct AgentCircleView: View { + let agent: Agent + let size: CGFloat + + init(agent: Agent, size: CGFloat = 56) { + self.agent = agent + self.size = size + } + + var body: some View { + VStack(spacing: 8) { + // Colored Circle + Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + agent.color.opacity(0.9), + agent.color.opacity(0.6) + ]), + center: .topLeading, + startRadius: 0, + endRadius: size + ) + ) + .frame(width: size, height: size) + .shadow(color: agent.color.opacity(0.3), radius: 8, x: 0, y: 4) + + // Agent Name + Text(agent.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + + // Message Label + Text("Message") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.pocketCyan) + } + } +} + +#Preview { + HStack(spacing: 30) { + ForEach(Agent.samples) { agent in + AgentCircleView(agent: agent) + } + } + .padding() + .background(Color.black) +} diff --git a/Pocket/Sources/Components/ChatBubbleView.swift b/Pocket/Sources/Components/ChatBubbleView.swift new file mode 100644 index 0000000..a29d085 --- /dev/null +++ b/Pocket/Sources/Components/ChatBubbleView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +struct ChatBubbleView: View { + let message: ChatMessage + + var body: some View { + switch message.sender { + case .agent(let name): + agentMessage(name: name) + case .user: + userMessage() + } + } + + // MARK: - Agent Message + @ViewBuilder + private func agentMessage(name: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + // Agent Avatar + Circle() + .fill(Color.agentStocks) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(name) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + + Text(message.time) + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.pocketSecondaryText) + } + + Text(message.text) + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.pocketSecondaryText) + } + } + + // Trade Badge + if let badge = message.tradeBadge { + TradeBadgeView(badge: badge) + .padding(.leading, 42) + } + } + } + + // MARK: - User Message + @ViewBuilder + private func userMessage() -> some View { + HStack { + Spacer() + Text(message.text) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule() + .fill(Color(red: 0.25, green: 0.25, blue: 0.28)) + ) + } + } +} + +// MARK: - Trade Badge View +struct TradeBadgeView: View { + let badge: TradeBadge + + var body: some View { + HStack(spacing: 8) { + // Ticker Icon + Circle() + .fill(Color.pocketRed) + .frame(width: 24, height: 24) + .overlay( + Text("T") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + ) + + Text(badge.displayText) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color(red: 0.18, green: 0.18, blue: 0.20)) + ) + } +} + +#Preview { + VStack(spacing: 16) { + ChatBubbleView(message: ChatMessage( + sender: .agent("Stocks Trader"), + text: "TSLA hit my entry range." + )) + + ChatBubbleView(message: ChatMessage( + sender: .user, + text: "Nice. What did you do?" + )) + + ChatBubbleView(message: ChatMessage( + sender: .agent("Stocks Trader"), + text: "Executed a buy", + tradeBadge: TradeBadge(action: .bought, amount: 24.3, ticker: "TSLA") + )) + } + .padding() + .background(Color.black) +} diff --git a/Pocket/Sources/Components/MiniChartView.swift b/Pocket/Sources/Components/MiniChartView.swift new file mode 100644 index 0000000..ed96425 --- /dev/null +++ b/Pocket/Sources/Components/MiniChartView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct MiniChartView: View { + let dataPoints: [CGFloat] + let lineColor: Color + let animated: Bool + + @State private var animationProgress: CGFloat = 0 + + init(dataPoints: [CGFloat], lineColor: Color = .pocketRed, animated: Bool = true) { + self.dataPoints = dataPoints + self.lineColor = lineColor + self.animated = animated + } + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + let minVal = dataPoints.min() ?? 0 + let maxVal = dataPoints.max() ?? 1 + let range = maxVal - minVal + + ZStack { + // Line Chart + Path { path in + guard dataPoints.count > 1 else { return } + + for (index, point) in dataPoints.enumerated() { + let x = width * CGFloat(index) / CGFloat(dataPoints.count - 1) + let normalizedY = range > 0 ? (point - minVal) / range : 0.5 + let y = height * (1 - normalizedY) + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .trim(from: 0, to: animated ? animationProgress : 1) + .stroke(lineColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + + // End Point Dot + if animationProgress >= 1 || !animated { + let lastPoint = dataPoints.last ?? 0 + let lastX = width + let normalizedY = range > 0 ? (lastPoint - minVal) / range : 0.5 + let lastY = height * (1 - normalizedY) + + Circle() + .fill(lineColor) + .frame(width: 6, height: 6) + .position(x: lastX, y: lastY) + } + } + } + .onAppear { + if animated { + withAnimation(.easeInOut(duration: 1.5)) { + animationProgress = 1 + } + } + } + } +} + +#Preview { + MiniChartView( + dataPoints: StockData.tslaSample.chartPoints, + lineColor: .pocketRed + ) + .frame(height: 60) + .padding() + .background(Color.black) +} diff --git a/Pocket/Sources/Components/PocketLogoView.swift b/Pocket/Sources/Components/PocketLogoView.swift new file mode 100644 index 0000000..1acebf2 --- /dev/null +++ b/Pocket/Sources/Components/PocketLogoView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct PocketLogoView: View { + var body: some View { + VStack(spacing: 8) { + // POCKET Logo Text + Text("POCKET") + .font(.system(size: 42, weight: .black, design: .rounded)) + .foregroundColor(.white) + .tracking(2) + + // Subtitle + Text("Your Crypto AI assistant") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + } +} + +#Preview { + PocketLogoView() + .background(Color.black) +} diff --git a/Pocket/Sources/Extensions/Color+Theme.swift b/Pocket/Sources/Extensions/Color+Theme.swift new file mode 100644 index 0000000..cdc8eb1 --- /dev/null +++ b/Pocket/Sources/Extensions/Color+Theme.swift @@ -0,0 +1,30 @@ +import SwiftUI + +extension Color { + // MARK: - Primary Colors + static let pocketCyan = Color(red: 0.0, green: 0.85, blue: 0.95) + static let pocketBackground = Color.black + static let pocketCardBackground = Color(red: 0.12, green: 0.12, blue: 0.14) + static let pocketCardBorder = Color(red: 0.2, green: 0.2, blue: 0.22) + + // MARK: - Agent Colors + static let agentStocks = Color(red: 0.78, green: 0.65, blue: 0.30) + static let agentPredictions = Color(red: 0.25, green: 0.45, blue: 0.90) + static let agentDefi = Color(red: 0.60, green: 0.45, blue: 0.80) + + // MARK: - Status Colors + static let pocketRed = Color(red: 0.90, green: 0.20, blue: 0.20) + static let pocketGreen = Color(red: 0.20, green: 0.80, blue: 0.40) + + // MARK: - Text Colors + static let pocketPrimaryText = Color.white + static let pocketSecondaryText = Color(red: 0.55, green: 0.55, blue: 0.60) + static let pocketTertiaryText = Color(red: 0.40, green: 0.40, blue: 0.45) + + // MARK: - Login Sheet Colors + static let loginSheetBackground = Color(red: 0.10, green: 0.10, blue: 0.12) + static let loginButtonBackground = Color(red: 0.18, green: 0.18, blue: 0.20) + static let loginButtonBorder = Color(red: 0.25, green: 0.25, blue: 0.28) + static let loginButtonHighlight = Color(red: 0.20, green: 0.20, blue: 0.23) + static let loginIconBackground = Color(red: 0.22, green: 0.22, blue: 0.25) +} diff --git a/Pocket/Sources/Models/Models.swift b/Pocket/Sources/Models/Models.swift new file mode 100644 index 0000000..ce9a67b --- /dev/null +++ b/Pocket/Sources/Models/Models.swift @@ -0,0 +1,286 @@ +import SwiftUI + +// MARK: - Agent Model +struct Agent: Identifiable { + let id = UUID() + let name: String + let color: Color + let icon: String + + static let samples: [Agent] = [ + Agent(name: "Stocks", color: .agentStocks, icon: "chart.line.uptrend.xyaxis"), + Agent(name: "Predictions", color: .agentPredictions, icon: "brain"), + Agent(name: "Defi", color: .agentDefi, icon: "bitcoinsign.circle") + ] +} + +// MARK: - Chat Message Model +struct ChatMessage: Identifiable { + let id = UUID() + let sender: MessageSender + let text: String + let time: String + let tradeBadge: TradeBadge? + + init(sender: MessageSender, text: String, time: String = "3:45 pm", tradeBadge: TradeBadge? = nil) { + self.sender = sender + self.text = text + self.time = time + self.tradeBadge = tradeBadge + } +} + +enum MessageSender { + case agent(String) + case user +} + +// MARK: - Trade Badge Model +struct TradeBadge: Identifiable { + let id = UUID() + let action: TradeAction + let amount: Double + let ticker: String + + var displayText: String { + "\(action.rawValue) \(String(format: "%.1f", amount)) \(ticker)" + } +} + +enum TradeAction: String { + case bought = "Bought" + case sold = "Sold" +} + +// MARK: - Stock Data Model +struct StockData: Identifiable { + let id = UUID() + let ticker: String + let price: Double + let chartPoints: [CGFloat] + let color: Color + + static let tslaSample = StockData( + ticker: "TSLA", + price: 569.8, + chartPoints: [0.5, 0.45, 0.55, 0.40, 0.48, 0.42, 0.50, 0.45, 0.52, 0.48, 0.55, 0.50, 0.58, 0.62, 0.65, 0.70], + color: .pocketRed + ) +} + +// MARK: - Carousel Card Type +enum CarouselCardType: Int, CaseIterable { + case portfolio = 0 + case chat = 1 + case stockChart = 2 +} + +// MARK: - Portfolio Data +struct PortfolioData { + let balance: Double + let changePercent: Double + let isDown: Bool + + static let sample = PortfolioData( + balance: 62.18, + changePercent: 6.1, + isDown: true + ) +} + +// MARK: - Tab Definition +enum AppTab: Int, CaseIterable { + case home = 0 + case chat = 1 + case market = 2 + case settings = 3 + + var title: String { + switch self { + case .home: return "Home" + case .chat: return "Chat" + case .market: return "Market" + case .settings: return "Settings" + } + } + + var icon: String { + switch self { + case .home: return "house.fill" + case .chat: return "bubble.left.and.bubble.right.fill" + case .market: return "chart.line.uptrend.xyaxis" + case .settings: return "gearshape.fill" + } + } +} + +// MARK: - Asset Model +struct Asset: Identifiable { + let id = UUID() + let name: String + let symbol: String + let iconName: String + let balance: Double + let value: Double + let changePercent: Double + let isUp: Bool + let chainType: AssetChainType + + enum AssetChainType { + case ethereum + case solana + } + + static let samples: [Asset] = [ + Asset( + name: "Ethereum", + symbol: "ETH", + iconName: "ethereum", + balance: 0.0185, + value: 48.62, + changePercent: 2.3, + isUp: true, + chainType: .ethereum + ), + Asset( + name: "Solana", + symbol: "SOL", + iconName: "solana", + balance: 0.082, + value: 13.56, + changePercent: 5.7, + isUp: false, + chainType: .solana + ) + ] +} + +// MARK: - Wallet Info +struct WalletInfo: Identifiable { + let id = UUID() + let address: String + let chainType: WalletChainType + let isEmbedded: Bool + + enum WalletChainType: String { + case ethereum = "Ethereum" + case solana = "Solana" + } + + var shortAddress: String { + guard address.count > 10 else { return address } + return "\(address.prefix(6))...\(address.suffix(4))" + } + + static let samples: [WalletInfo] = [ + WalletInfo( + address: "0x1234...abcd", + chainType: .ethereum, + isEmbedded: true + ), + WalletInfo( + address: "5FHn...9xKz", + chainType: .solana, + isEmbedded: true + ) + ] +} + +// MARK: - Agent Status +enum AgentStatus: String { + case running = "Running" + case paused = "Paused" + case stopped = "Stopped" + + var color: Color { + switch self { + case .running: return .pocketGreen + case .paused: return .agentStocks + case .stopped: return .pocketRed + } + } +} + +// MARK: - Agent Detail +struct AgentDetail: Identifiable { + let id = UUID() + let agent: Agent + let status: AgentStatus + let description: String + let profitLoss: Double + + static let samples: [AgentDetail] = [ + AgentDetail( + agent: Agent.samples[0], + status: .running, + description: "Analyzing stock markets and executing trades", + profitLoss: 12.5 + ), + AgentDetail( + agent: Agent.samples[1], + status: .running, + description: "Predicting market trends with AI models", + profitLoss: -3.2 + ), + AgentDetail( + agent: Agent.samples[2], + status: .paused, + description: "Managing DeFi positions and yield farming", + profitLoss: 8.7 + ) + ] +} + +// MARK: - Market Item +struct MarketItem: Identifiable { + let id = UUID() + let name: String + let symbol: String + let price: Double + let changePercent: Double + let isUp: Bool + let chartPoints: [CGFloat] + + static let samples: [MarketItem] = [ + MarketItem( + name: "Bitcoin", + symbol: "BTC", + price: 67_432.50, + changePercent: 1.8, + isUp: true, + chartPoints: [0.4, 0.42, 0.45, 0.43, 0.48, 0.50, 0.52, 0.55, 0.53, 0.58] + ), + MarketItem( + name: "Ethereum", + symbol: "ETH", + price: 2_628.30, + changePercent: 2.3, + isUp: true, + chartPoints: [0.5, 0.48, 0.52, 0.55, 0.53, 0.58, 0.60, 0.57, 0.62, 0.65] + ), + MarketItem( + name: "Solana", + symbol: "SOL", + price: 165.42, + changePercent: 5.7, + isUp: false, + chartPoints: [0.7, 0.68, 0.65, 0.63, 0.60, 0.58, 0.55, 0.52, 0.50, 0.48] + ), + MarketItem( + name: "Chainlink", + symbol: "LINK", + price: 14.85, + changePercent: 3.1, + isUp: true, + chartPoints: [0.3, 0.35, 0.38, 0.40, 0.42, 0.45, 0.48, 0.50, 0.52, 0.55] + ), + MarketItem( + name: "Uniswap", + symbol: "UNI", + price: 7.23, + changePercent: 1.2, + isUp: false, + chartPoints: [0.6, 0.58, 0.55, 0.57, 0.54, 0.52, 0.50, 0.48, 0.50, 0.47] + ) + ] +} diff --git a/Pocket/Sources/PocketApp.swift b/Pocket/Sources/PocketApp.swift new file mode 100644 index 0000000..513858b --- /dev/null +++ b/Pocket/Sources/PocketApp.swift @@ -0,0 +1,21 @@ +import SwiftUI + +@main +struct PocketApp: App { + @StateObject private var authViewModel = AuthViewModel() + + var body: some Scene { + WindowGroup { + Group { + switch authViewModel.authState { + case .unauthenticated, .authenticating: + WelcomeView(authViewModel: authViewModel) + case .authenticated: + MainTabView(authViewModel: authViewModel) + } + } + .animation(.easeInOut(duration: 0.4), value: authViewModel.authState == .authenticated) + .preferredColorScheme(.dark) + } + } +} diff --git a/Pocket/Sources/ViewModels/AuthViewModel.swift b/Pocket/Sources/ViewModels/AuthViewModel.swift new file mode 100644 index 0000000..543f6de --- /dev/null +++ b/Pocket/Sources/ViewModels/AuthViewModel.swift @@ -0,0 +1,179 @@ +import SwiftUI +import Combine + +// MARK: - Login Sheet Page +enum LoginSheetPage { + case main + case otherSocials + case walletSelection +} + +// MARK: - Auth State +enum AuthState { + case unauthenticated + case authenticating + case authenticated +} + +// MARK: - Wallet Option +struct WalletOption: Identifiable { + let id = UUID() + let name: String + let iconName: String + let chains: [ChainType] + + enum ChainType { + case ethereum + case solana + case multichain + } +} + +class AuthViewModel: ObservableObject { + // MARK: - Published Properties + @Published var showLoginSheet = false + @Published var currentPage: LoginSheetPage = .main + @Published var authState: AuthState = .unauthenticated + @Published var emailInput: String = "" + @Published var isEmailValid: Bool = false + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + // MARK: - Wallet Options + let walletOptions: [WalletOption] = [ + WalletOption(name: "MetaMask", iconName: "metamask", chains: [.ethereum]), + WalletOption(name: "Coinbase Wallet", iconName: "coinbase", chains: [.ethereum]), + WalletOption(name: "Rainbow", iconName: "rainbow", chains: [.ethereum]), + WalletOption(name: "Base", iconName: "base", chains: [.ethereum]), + WalletOption(name: "1inch Wallet", iconName: "1inch", chains: [.ethereum, .solana]) + ] + + // MARK: - Privy Configuration + // Configure via environment or Secrets.xcconfig + private let privyAppId: String = { + // Load from Info.plist or environment + Bundle.main.object(forInfoDictionaryKey: "PRIVY_APP_ID") as? String ?? "" + }() + + // MARK: - Actions + func showLogin() { + currentPage = .main + showLoginSheet = true + } + + func dismissLogin() { + showLoginSheet = false + currentPage = .main + emailInput = "" + errorMessage = nil + } + + func navigateToOtherSocials() { + withAnimation(.easeInOut(duration: 0.3)) { + currentPage = .otherSocials + } + } + + func navigateToWalletSelection() { + withAnimation(.easeInOut(duration: 0.3)) { + currentPage = .walletSelection + } + } + + func navigateBack() { + withAnimation(.easeInOut(duration: 0.3)) { + currentPage = .main + } + } + + func validateEmail() { + let pattern = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" + let predicate = NSPredicate(format: "SELF MATCHES %@", pattern) + isEmailValid = predicate.evaluate(with: emailInput) + } + + // MARK: - Auth Methods (Privy SDK integration points) + + func loginWithMetaMask() { + isLoading = true + // TODO: Integrate with Privy SDK + // privy.externalWallet.connect(provider: .metamask) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.isLoading = false + } + } + + func loginWithEmail() { + guard isEmailValid else { return } + isLoading = true + // TODO: Integrate with Privy SDK + // try await privy.email.sendCode(to: emailInput) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.isLoading = false + } + } + + func loginWithGoogle() { + isLoading = true + // TODO: Integrate with Privy SDK + // try await privy.google.login() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.isLoading = false + } + } + + func loginWithTwitter() { + isLoading = true + // TODO: Integrate with Privy SDK + // try await privy.twitter.login() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.isLoading = false + } + } + + func loginWithDiscord() { + isLoading = true + // TODO: Integrate with Privy SDK + // try await privy.discord.login() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.isLoading = false + } + } + + func loginWithTelegram() { + isLoading = true + // TODO: Integrate with Privy SDK + // try await privy.telegram.login() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.isLoading = false + } + } + + func connectWallet(_ wallet: WalletOption) { + isLoading = true + // TODO: Integrate with Privy SDK + // privy.externalWallet.connect(provider: wallet) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.isLoading = false + } + } + + // MARK: - Mock Login (for development) + func mockLogin() { + isLoading = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + guard let self = self else { return } + self.isLoading = false + self.showLoginSheet = false + self.authState = .authenticated + } + } + + func logout() { + // TODO: Integrate with Privy SDK + // try await privy.logout() + authState = .unauthenticated + emailInput = "" + errorMessage = nil + } +} diff --git a/Pocket/Sources/ViewModels/ChatViewModel.swift b/Pocket/Sources/ViewModels/ChatViewModel.swift new file mode 100644 index 0000000..b320e66 --- /dev/null +++ b/Pocket/Sources/ViewModels/ChatViewModel.swift @@ -0,0 +1,63 @@ +import SwiftUI +import Combine + +class ChatViewModel: ObservableObject { + // MARK: - Published Properties + @Published var messages: [ChatMessage] = [] + @Published var inputText: String = "" + @Published var selectedAgent: Agent = Agent.samples[0] + @Published var isSending: Bool = false + + init() { + loadSampleMessages() + } + + // MARK: - Actions + func sendMessage() { + guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let userMessage = ChatMessage( + sender: .user, + text: inputText, + time: currentTimeString() + ) + messages.append(userMessage) + let sentText = inputText + inputText = "" + isSending = true + + // TODO: Integrate with AI agent backend + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self = self else { return } + let agentResponse = ChatMessage( + sender: .agent(self.selectedAgent.name), + text: "I'm analyzing your request: \"\(sentText)\". Let me check the markets.", + time: self.currentTimeString() + ) + self.messages.append(agentResponse) + self.isSending = false + } + } + + func selectAgent(_ agent: Agent) { + selectedAgent = agent + messages.removeAll() + loadSampleMessages() + } + + // MARK: - Private + private func loadSampleMessages() { + messages = [ + ChatMessage( + sender: .agent(selectedAgent.name), + text: "Hello! I'm your \(selectedAgent.name) agent. How can I help you today?", + time: "9:00 am" + ) + ] + } + + private func currentTimeString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter.string(from: Date()) + } +} diff --git a/Pocket/Sources/ViewModels/MarketViewModel.swift b/Pocket/Sources/ViewModels/MarketViewModel.swift new file mode 100644 index 0000000..6d751a9 --- /dev/null +++ b/Pocket/Sources/ViewModels/MarketViewModel.swift @@ -0,0 +1,43 @@ +import SwiftUI +import Combine + +class MarketViewModel: ObservableObject { + // MARK: - Published Properties + @Published var marketItems: [MarketItem] = MarketItem.samples + @Published var searchText: String = "" + @Published var isLoading: Bool = false + @Published var selectedTimeframe: MarketTimeframe = .day + + enum MarketTimeframe: String, CaseIterable { + case hour = "1H" + case day = "1D" + case week = "1W" + case month = "1M" + case year = "1Y" + } + + // MARK: - Computed Properties + var filteredItems: [MarketItem] { + if searchText.isEmpty { + return marketItems + } + return marketItems.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + $0.symbol.localizedCaseInsensitiveContains(searchText) + } + } + + // MARK: - Actions + func refreshMarket() { + isLoading = true + // TODO: Fetch real market data from API + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.isLoading = false + } + } + + func selectTimeframe(_ timeframe: MarketTimeframe) { + selectedTimeframe = timeframe + refreshMarket() + } +} diff --git a/Pocket/Sources/ViewModels/PortfolioViewModel.swift b/Pocket/Sources/ViewModels/PortfolioViewModel.swift new file mode 100644 index 0000000..52d378d --- /dev/null +++ b/Pocket/Sources/ViewModels/PortfolioViewModel.swift @@ -0,0 +1,42 @@ +import SwiftUI +import Combine + +class PortfolioViewModel: ObservableObject { + // MARK: - Published Properties + @Published var portfolio: PortfolioData = .sample + @Published var assets: [Asset] = Asset.samples + @Published var wallets: [WalletInfo] = WalletInfo.samples + @Published var agentDetails: [AgentDetail] = AgentDetail.samples + @Published var isRefreshing: Bool = false + + // MARK: - Computed Properties + var totalBalance: Double { + assets.reduce(0) { $0 + $1.value } + } + + var activeAgentsCount: Int { + agentDetails.filter { $0.status == .running }.count + } + + // MARK: - Actions + func refreshPortfolio() { + isRefreshing = true + // TODO: Integrate with Privy SDK to fetch real wallet balances + // privy.embeddedWallet.getBalance() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.isRefreshing = false + } + } + + func toggleAgent(_ agentDetail: AgentDetail) { + guard let index = agentDetails.firstIndex(where: { $0.id == agentDetail.id }) else { return } + let current = agentDetails[index] + let newStatus: AgentStatus = current.status == .running ? .paused : .running + agentDetails[index] = AgentDetail( + agent: current.agent, + status: newStatus, + description: current.description, + profitLoss: current.profitLoss + ) + } +} diff --git a/Pocket/Sources/ViewModels/WelcomeViewModel.swift b/Pocket/Sources/ViewModels/WelcomeViewModel.swift new file mode 100644 index 0000000..9d5ed8a --- /dev/null +++ b/Pocket/Sources/ViewModels/WelcomeViewModel.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Combine + +class WelcomeViewModel: ObservableObject { + // MARK: - Published Properties + @Published var currentCardIndex: Int = 0 + @Published var showCardContent: Bool = false + @Published var autoScrollEnabled: Bool = true + + // MARK: - Data + let portfolio = PortfolioData.sample + let agents = Agent.samples + let stock = StockData.tslaSample + + let chatMessages: [ChatMessage] = [ + ChatMessage( + sender: .agent("Stocks Trader"), + text: "TSLA hit my entry range." + ), + ChatMessage( + sender: .user, + text: "Nice. What did you do?" + ), + ChatMessage( + sender: .agent("Stocks Trader"), + text: "Executed a buy", + tradeBadge: TradeBadge(action: .bought, amount: 24.3, ticker: "TSLA") + ) + ] + + // MARK: - Timer + private var timer: AnyCancellable? + private let autoScrollInterval: TimeInterval = 4.0 + + init() { + startAutoScroll() + } + + deinit { + stopAutoScroll() + } + + // MARK: - Auto Scroll + func startAutoScroll() { + timer = Timer.publish(every: autoScrollInterval, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self = self, self.autoScrollEnabled else { return } + withAnimation(.easeInOut(duration: 0.6)) { + self.currentCardIndex = (self.currentCardIndex + 1) % CarouselCardType.allCases.count + } + self.animateCardContent() + } + } + + func stopAutoScroll() { + timer?.cancel() + timer = nil + } + + func animateCardContent() { + showCardContent = false + withAnimation(.easeIn(duration: 0.4).delay(0.3)) { + showCardContent = true + } + } + + func selectCard(_ index: Int) { + autoScrollEnabled = false + withAnimation(.easeInOut(duration: 0.5)) { + currentCardIndex = index + } + animateCardContent() + + // Resume auto-scroll after 8 seconds of inactivity + DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in + self?.autoScrollEnabled = true + } + } +} diff --git a/Pocket/Sources/Views/Auth/LoginSheetView.swift b/Pocket/Sources/Views/Auth/LoginSheetView.swift new file mode 100644 index 0000000..65939c3 --- /dev/null +++ b/Pocket/Sources/Views/Auth/LoginSheetView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +struct LoginSheetView: View { + @ObservedObject var authViewModel: AuthViewModel + + var body: some View { + ZStack { + Color.black.opacity(0.001) + .ignoresSafeArea() + .onTapGesture { + authViewModel.dismissLogin() + } + + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 0) { + switch authViewModel.currentPage { + case .main: + MainLoginContent(authViewModel: authViewModel) + .transition(.asymmetric( + insertion: .move(edge: .leading).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + case .otherSocials: + OtherSocialsContent(authViewModel: authViewModel) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity) + )) + case .walletSelection: + WalletSelectionContent(authViewModel: authViewModel) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .trailing).combined(with: .opacity) + )) + } + + // Footer + LoginFooterView() + } + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color.loginSheetBackground) + ) + .padding(.horizontal, 0) + } + .ignoresSafeArea(edges: .bottom) + } + } +} + +// MARK: - Main Login Content +struct MainLoginContent: View { + @ObservedObject var authViewModel: AuthViewModel + + var body: some View { + VStack(spacing: 16) { + // Close button + HStack { + Spacer() + Button(action: { authViewModel.dismissLogin() }, label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.loginButtonBackground)) + }) + } + .padding(.horizontal, 20) + .padding(.top, 16) + + // Logo + Text("POCKET") + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .tracking(2) + + // Title + Text("Log in or sign up") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.white) + .padding(.bottom, 8) + + // Login Options + VStack(spacing: 12) { + // MetaMask + LoginOptionButton( + iconName: "metamask_icon", + systemIcon: "globe", + title: "MetaMask", + showLastUsed: true, + showChainIcon: true, + action: { authViewModel.mockLogin() } + ) + + // Email Input + EmailInputRow(authViewModel: authViewModel) + + // Google + LoginOptionButton( + iconName: "google_icon", + systemIcon: "g.circle.fill", + title: "Google", + isHighlighted: true, + action: { authViewModel.mockLogin() } + ) + + // Other socials + LoginOptionButton( + iconName: "socials_icon", + systemIcon: "person.circle", + title: "Other socials", + action: { authViewModel.navigateToOtherSocials() } + ) + + // Continue with a wallet + LoginOptionButton( + iconName: "wallet_icon", + systemIcon: "wallet.pass", + title: "Continue with a wallet", + action: { authViewModel.navigateToWalletSelection() } + ) + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + } + } +} + +// MARK: - Email Input Row +struct EmailInputRow: View { + @ObservedObject var authViewModel: AuthViewModel + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "envelope") + .font(.system(size: 18)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 36, height: 36) + .background(Circle().fill(Color.loginIconBackground)) + + TextField("your@email.com", text: $authViewModel.emailInput) + .font(.system(size: 16)) + .foregroundColor(.white) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: authViewModel.emailInput) { + authViewModel.validateEmail() + } + + Button(action: { authViewModel.loginWithEmail() }, label: { + Text("Submit") + .font(.system(size: 14, weight: .medium)) + .foregroundColor( + authViewModel.isEmailValid + ? .white + : .pocketTertiaryText + ) + }) + .disabled(!authViewModel.isEmailValid) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.loginButtonBorder, lineWidth: 1) + ) + } +} + +// MARK: - Login Option Button +struct LoginOptionButton: View { + let iconName: String + var systemIcon: String = "circle" + let title: String + var showLastUsed: Bool = false + var showChainIcon: Bool = false + var isHighlighted: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemIcon) + .font(.system(size: 18)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background(Circle().fill(Color.loginIconBackground)) + + Text(title) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + + Spacer() + + if showLastUsed { + Text("Last used") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color.loginButtonBackground) + ) + } + + if showChainIcon { + Image(systemName: "diamond.fill") + .font(.system(size: 14)) + .foregroundColor(.blue) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(isHighlighted ? Color.loginButtonHighlight : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.loginButtonBorder, lineWidth: 1) + ) + } + } +} + +// MARK: - Footer +struct LoginFooterView: View { + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 4) { + Text("By logging in I agree to the") + .font(.system(size: 12)) + .foregroundColor(.pocketSecondaryText) + + Button(action: { + // Open Privacy Policy + }, label: { + Text("Privacy Policy") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + }) + } + + HStack(spacing: 6) { + Text("Protected by") + .font(.system(size: 12)) + .foregroundColor(.pocketTertiaryText) + + Circle() + .fill(Color.green) + .frame(width: 6, height: 6) + + Text("privy") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.pocketSecondaryText) + } + } + .padding(.vertical, 20) + } +} + +#Preview { + ZStack { + Color.black.ignoresSafeArea() + LoginSheetView(authViewModel: AuthViewModel()) + } +} diff --git a/Pocket/Sources/Views/Auth/OtherSocialsContent.swift b/Pocket/Sources/Views/Auth/OtherSocialsContent.swift new file mode 100644 index 0000000..09d6737 --- /dev/null +++ b/Pocket/Sources/Views/Auth/OtherSocialsContent.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct OtherSocialsContent: View { + @ObservedObject var authViewModel: AuthViewModel + + var body: some View { + VStack(spacing: 16) { + // Navigation bar + HStack { + Button(action: { authViewModel.navigateBack() }, label: { + Image(systemName: "arrow.left") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.loginButtonBackground)) + }) + + Spacer() + + Button(action: { authViewModel.dismissLogin() }, label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.loginButtonBackground)) + }) + } + .padding(.horizontal, 20) + .padding(.top, 16) + + // Title + Text("Log in or sign up") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.white) + .padding(.top, 8) + .padding(.bottom, 8) + + // Social Options + VStack(spacing: 12) { + // Twitter / X + SocialLoginButton( + systemIcon: "xmark", + title: "Twitter", + iconBackground: Color.white, + iconForeground: Color.black, + action: { authViewModel.loginWithTwitter() } + ) + + // Discord + SocialLoginButton( + systemIcon: "message.fill", + title: "Discord", + iconBackground: Color(red: 0.34, green: 0.40, blue: 0.95), + iconForeground: .white, + action: { authViewModel.loginWithDiscord() } + ) + + // Telegram + SocialLoginButton( + systemIcon: "paperplane.fill", + title: "Telegram", + iconBackground: Color(red: 0.40, green: 0.67, blue: 0.88), + iconForeground: .white, + action: { authViewModel.loginWithTelegram() } + ) + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + } + } +} + +// MARK: - Social Login Button +struct SocialLoginButton: View { + let systemIcon: String + let title: String + var iconBackground: Color = .loginIconBackground + var iconForeground: Color = .white + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemIcon) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(iconForeground) + .frame(width: 36, height: 36) + .background(Circle().fill(iconBackground)) + + Text(title) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.loginButtonBorder, lineWidth: 1) + ) + } + } +} + +#Preview { + ZStack { + Color.black.ignoresSafeArea() + VStack { + Spacer() + OtherSocialsContent(authViewModel: AuthViewModel()) + .background(Color.loginSheetBackground) + } + } +} diff --git a/Pocket/Sources/Views/Auth/WalletSelectionContent.swift b/Pocket/Sources/Views/Auth/WalletSelectionContent.swift new file mode 100644 index 0000000..86339f8 --- /dev/null +++ b/Pocket/Sources/Views/Auth/WalletSelectionContent.swift @@ -0,0 +1,183 @@ +import SwiftUI + +struct WalletSelectionContent: View { + @ObservedObject var authViewModel: AuthViewModel + @State private var searchText: String = "" + + private var filteredWallets: [WalletOption] { + if searchText.isEmpty { + return authViewModel.walletOptions + } + return authViewModel.walletOptions.filter { + $0.name.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + VStack(spacing: 16) { + // Navigation bar + HStack { + Button(action: { authViewModel.navigateBack() }, label: { + Image(systemName: "arrow.left") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.loginButtonBackground)) + }) + + Spacer() + + Button(action: { authViewModel.dismissLogin() }, label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.loginButtonBackground)) + }) + } + .padding(.horizontal, 20) + .padding(.top, 16) + + // Wallet icon + Image(systemName: "wallet.pass") + .font(.system(size: 28)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 56, height: 56) + .background(Circle().fill(Color.loginButtonBackground)) + + // Title + Text("Select your wallet") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.white) + .padding(.bottom, 4) + + // Search field + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.loginIconBackground)) + + TextField( + "Search through \(authViewModel.walletOptions.count) wallets", + text: $searchText + ) + .font(.system(size: 15)) + .foregroundColor(.white) + .autocapitalization(.none) + .disableAutocorrection(true) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.loginButtonHighlight) + ) + .padding(.horizontal, 20) + + // Wallet list + ScrollView { + VStack(spacing: 10) { + ForEach(filteredWallets) { wallet in + WalletRowButton(wallet: wallet) { + authViewModel.connectWallet(wallet) + } + } + } + .padding(.horizontal, 20) + } + .frame(maxHeight: 320) + .padding(.bottom, 16) + } + } +} + +// MARK: - Wallet Row Button +struct WalletRowButton: View { + let wallet: WalletOption + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + walletIcon + + Text(wallet.name) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + + Spacer() + + chainIcons + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.loginButtonBorder, lineWidth: 1) + ) + } + } + + @ViewBuilder + private var walletIcon: some View { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(walletIconColor) + .frame(width: 36, height: 36) + + Text(String(wallet.name.prefix(1))) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + } + } + + private var walletIconColor: Color { + switch wallet.name { + case "MetaMask": + return Color(red: 0.88, green: 0.56, blue: 0.20) + case "Coinbase Wallet": + return Color(red: 0.20, green: 0.40, blue: 0.95) + case "Rainbow": + return Color(red: 0.95, green: 0.50, blue: 0.20) + case "Base": + return Color(red: 0.20, green: 0.33, blue: 1.0) + default: + return Color.loginIconBackground + } + } + + @ViewBuilder + private var chainIcons: some View { + HStack(spacing: 4) { + ForEach(wallet.chains.indices, id: \.self) { index in + switch wallet.chains[index] { + case .ethereum: + Image(systemName: "diamond.fill") + .font(.system(size: 12)) + .foregroundColor(.blue) + case .solana: + Image(systemName: "circle.hexagongrid.fill") + .font(.system(size: 12)) + .foregroundColor(.purple) + case .multichain: + Image(systemName: "circle.grid.2x2.fill") + .font(.system(size: 12)) + .foregroundColor(.green) + } + } + } + } +} + +#Preview { + ZStack { + Color.black.ignoresSafeArea() + VStack { + Spacer() + WalletSelectionContent(authViewModel: AuthViewModel()) + .background(Color.loginSheetBackground) + } + } +} diff --git a/Pocket/Sources/Views/Cards/CarouselCardContainer.swift b/Pocket/Sources/Views/Cards/CarouselCardContainer.swift new file mode 100644 index 0000000..a2daebb --- /dev/null +++ b/Pocket/Sources/Views/Cards/CarouselCardContainer.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct CarouselCardContainer: View { + @ObservedObject var viewModel: WelcomeViewModel + + var body: some View { + TabView(selection: $viewModel.currentCardIndex) { + // Card 1: Portfolio Overview + cardWrapper(index: 0) { + PortfolioCardView( + portfolio: viewModel.portfolio, + agents: viewModel.agents, + isVisible: viewModel.currentCardIndex == 0 + ) + } + + // Card 2: Chat with Agent + cardWrapper(index: 1) { + ChatCardView( + messages: viewModel.chatMessages, + isVisible: viewModel.currentCardIndex == 1 + ) + } + + // Card 3: Stock Chart + cardWrapper(index: 2) { + StockChartCardView( + stock: viewModel.stock, + isVisible: viewModel.currentCardIndex == 2 + ) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 380) + .onChange(of: viewModel.currentCardIndex) { _ in + viewModel.animateCardContent() + } + } + + @ViewBuilder + private func cardWrapper(index: Int, @ViewBuilder content: () -> Content) -> some View { + content() + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color.pocketCardBackground) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.pocketCardBorder, lineWidth: 1) + ) + ) + .padding(.horizontal, 24) + .tag(index) + } +} + +#Preview { + CarouselCardContainer(viewModel: WelcomeViewModel()) + .background(Color.black) +} diff --git a/Pocket/Sources/Views/Cards/ChatCardView.swift b/Pocket/Sources/Views/Cards/ChatCardView.swift new file mode 100644 index 0000000..70c05d5 --- /dev/null +++ b/Pocket/Sources/Views/Cards/ChatCardView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct ChatCardView: View { + let messages: [ChatMessage] + let isVisible: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Yellow Header Bar + RoundedRectangle(cornerRadius: 12) + .fill(Color(red: 0.90, green: 0.78, blue: 0.20)) + .frame(height: 36) + .padding(.horizontal, -20) + .padding(.top, -24) + .clipShape( + RoundedCorner(radius: 20, corners: [.topLeft, .topRight]) + ) + + // Chat Messages + ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in + ChatBubbleView(message: message) + .opacity(isVisible ? 1 : 0) + .animation( + .easeIn(duration: 0.4).delay(Double(index) * 0.3 + 0.2), + value: isVisible + ) + .offset(y: isVisible ? 0 : 10) + .animation( + .easeOut(duration: 0.4).delay(Double(index) * 0.3 + 0.2), + value: isVisible + ) + } + + Spacer(minLength: 0) + } + .padding(.vertical, 24) + .padding(.horizontal, 20) + } +} + +// MARK: - Rounded Corner Helper +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} + +#Preview { + ChatCardView( + messages: [ + ChatMessage(sender: .agent("Stocks Trader"), text: "TSLA hit my entry range."), + ChatMessage(sender: .user, text: "Nice. What did you do?"), + ChatMessage( + sender: .agent("Stocks Trader"), + text: "Executed a buy", + tradeBadge: TradeBadge(action: .bought, amount: 24.3, ticker: "TSLA") + ) + ], + isVisible: true + ) + .background(Color.pocketCardBackground) + .cornerRadius(20) + .padding() + .background(Color.black) +} diff --git a/Pocket/Sources/Views/Cards/PortfolioCardView.swift b/Pocket/Sources/Views/Cards/PortfolioCardView.swift new file mode 100644 index 0000000..bd4f8b8 --- /dev/null +++ b/Pocket/Sources/Views/Cards/PortfolioCardView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct PortfolioCardView: View { + let portfolio: PortfolioData + let agents: [Agent] + let isVisible: Bool + + var body: some View { + VStack(spacing: 16) { + // Balance + Text("$\(String(format: "%.2f", portfolio.balance))") + .font(.system(size: 38, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .opacity(isVisible ? 1 : 0) + .animation(.easeIn(duration: 0.4).delay(0.1), value: isVisible) + + // Change Indicator + HStack(spacing: 6) { + Text("down") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.pocketRed) + + Text("\(String(format: "%.1f", portfolio.changePercent))%") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.pocketRed.opacity(0.3)) + ) + } + .opacity(isVisible ? 1 : 0) + .animation(.easeIn(duration: 0.4).delay(0.2), value: isVisible) + + // Pill Buttons + HStack(spacing: 10) { + ForEach(0..<3) { _ in + Capsule() + .fill(Color(red: 0.22, green: 0.22, blue: 0.25)) + .frame(width: 60, height: 28) + } + } + .opacity(isVisible ? 1 : 0) + .animation(.easeIn(duration: 0.4).delay(0.3), value: isVisible) + + // Agents Section + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Agents") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.pocketSecondaryText) + + Spacer() + } + .padding(.leading, 4) + + // Agent Icons + HStack(spacing: 24) { + Spacer() + ForEach(agents) { agent in + AgentCircleView(agent: agent) + } + Spacer() + } + } + .opacity(isVisible ? 1 : 0) + .animation(.easeIn(duration: 0.4).delay(0.4), value: isVisible) + } + .padding(.vertical, 24) + .padding(.horizontal, 20) + } +} + +#Preview { + PortfolioCardView( + portfolio: .sample, + agents: Agent.samples, + isVisible: true + ) + .background(Color.pocketCardBackground) + .cornerRadius(20) + .padding() + .background(Color.black) +} diff --git a/Pocket/Sources/Views/Cards/StockChartCardView.swift b/Pocket/Sources/Views/Cards/StockChartCardView.swift new file mode 100644 index 0000000..455e782 --- /dev/null +++ b/Pocket/Sources/Views/Cards/StockChartCardView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct StockChartCardView: View { + let stock: StockData + let isVisible: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Red Header Bar + RoundedRectangle(cornerRadius: 12) + .fill(Color.pocketRed) + .frame(height: 36) + .padding(.horizontal, -20) + .padding(.top, -24) + .clipShape( + RoundedCorner(radius: 20, corners: [.topLeft, .topRight]) + ) + + // Stock Info + HStack(spacing: 12) { + // Ticker Icon + Circle() + .fill(Color.pocketRed) + .frame(width: 40, height: 40) + .overlay( + Text("T") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(stock.ticker) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.pocketSecondaryText) + + Text("$\(String(format: "%.1f", stock.price))") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + } + } + .opacity(isVisible ? 1 : 0) + .animation(.easeIn(duration: 0.4).delay(0.2), value: isVisible) + + // Mini Chart + MiniChartView( + dataPoints: stock.chartPoints, + lineColor: stock.color, + animated: isVisible + ) + .frame(height: 60) + .opacity(isVisible ? 1 : 0) + .animation(.easeIn(duration: 0.4).delay(0.4), value: isVisible) + + // Stocks Agent Status + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(Color.agentStocks) + .frame(width: 24, height: 24) + + Text("Stocks Agent") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + + Text("Running") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + + Text("Continuously analyzing the market and\nmanaging your position.") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.pocketSecondaryText) + .lineSpacing(3) + .padding(.leading, 32) + } + .opacity(isVisible ? 1 : 0) + .animation(.easeIn(duration: 0.4).delay(0.6), value: isVisible) + + Spacer(minLength: 0) + } + .padding(.vertical, 24) + .padding(.horizontal, 20) + } +} + +#Preview { + StockChartCardView( + stock: .tslaSample, + isVisible: true + ) + .background(Color.pocketCardBackground) + .cornerRadius(20) + .padding() + .background(Color.black) +} diff --git a/Pocket/Sources/Views/Main/ChatView.swift b/Pocket/Sources/Views/Main/ChatView.swift new file mode 100644 index 0000000..9d8ae51 --- /dev/null +++ b/Pocket/Sources/Views/Main/ChatView.swift @@ -0,0 +1,208 @@ +import SwiftUI + +struct ChatView: View { + @ObservedObject var chatViewModel: ChatViewModel + + var body: some View { + VStack(spacing: 0) { + // Header + ChatHeaderView( + selectedAgent: chatViewModel.selectedAgent, + onSelectAgent: { agent in chatViewModel.selectAgent(agent) } + ) + + // Messages + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 16) { + ForEach(chatViewModel.messages) { message in + ChatBubbleView(message: message) + .id(message.id) + } + + if chatViewModel.isSending { + TypingIndicatorView(agentName: chatViewModel.selectedAgent.name) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + .onChange(of: chatViewModel.messages.count) { + if let lastMessage = chatViewModel.messages.last { + withAnimation { + proxy.scrollTo(lastMessage.id, anchor: .bottom) + } + } + } + } + + // Input Bar + ChatInputBar( + inputText: $chatViewModel.inputText, + isSending: chatViewModel.isSending, + onSend: { chatViewModel.sendMessage() } + ) + + // Spacer for tab bar + Spacer().frame(height: 60) + } + .background(Color.pocketBackground.ignoresSafeArea()) + } +} + +// MARK: - Chat Header +struct ChatHeaderView: View { + let selectedAgent: Agent + let onSelectAgent: (Agent) -> Void + + var body: some View { + VStack(spacing: 12) { + // Title + HStack { + Text("Chat") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 16) + + // Agent Selector + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(Agent.samples) { agent in + AgentChipView( + agent: agent, + isSelected: agent.name == selectedAgent.name, + onTap: { onSelectAgent(agent) } + ) + } + } + .padding(.horizontal, 20) + } + } + .padding(.bottom, 8) + .background(Color.pocketBackground) + } +} + +struct AgentChipView: View { + let agent: Agent + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap, label: { + HStack(spacing: 8) { + Circle() + .fill(agent.color) + .frame(width: 24, height: 24) + .overlay( + Image(systemName: agent.icon) + .font(.system(size: 10)) + .foregroundColor(.white) + ) + + Text(agent.name) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(isSelected ? .white : .pocketSecondaryText) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule() + .fill(isSelected ? agent.color.opacity(0.25) : Color.pocketCardBackground) + .overlay( + Capsule() + .stroke(isSelected ? agent.color : Color.pocketCardBorder, lineWidth: 1) + ) + ) + }) + } +} + +// MARK: - Typing Indicator +struct TypingIndicatorView: View { + let agentName: String + @State private var dotCount = 0 + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(Color.agentStocks) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(agentName) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + + HStack(spacing: 4) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Color.pocketSecondaryText) + .frame(width: 6, height: 6) + .opacity(dotCount > index ? 1 : 0.3) + } + } + } + + Spacer() + } + .onAppear { + Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in + withAnimation { + dotCount = (dotCount + 1) % 4 + } + } + } + } +} + +// MARK: - Chat Input Bar +struct ChatInputBar: View { + @Binding var inputText: String + let isSending: Bool + let onSend: () -> Void + + var body: some View { + HStack(spacing: 12) { + TextField("Message your agent...", text: $inputText) + .font(.system(size: 15)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color.pocketCardBackground) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.pocketCardBorder, lineWidth: 1) + ) + ) + .onSubmit { + onSend() + } + + Button(action: onSend, label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 32)) + .foregroundColor( + inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending + ? .pocketSecondaryText + : .pocketCyan + ) + }) + .disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.pocketBackground) + } +} + +#Preview { + ChatView(chatViewModel: ChatViewModel()) + .preferredColorScheme(.dark) +} diff --git a/Pocket/Sources/Views/Main/HomeView.swift b/Pocket/Sources/Views/Main/HomeView.swift new file mode 100644 index 0000000..ab08b9f --- /dev/null +++ b/Pocket/Sources/Views/Main/HomeView.swift @@ -0,0 +1,349 @@ +import SwiftUI + +struct HomeView: View { + @ObservedObject var portfolioViewModel: PortfolioViewModel + @ObservedObject var authViewModel: AuthViewModel + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Header + HomeHeaderView() + + // Balance Card + BalanceCardView(portfolio: portfolioViewModel.portfolio) + + // Quick Actions + QuickActionsView() + + // Agents Section + AgentsSectionView(agentDetails: portfolioViewModel.agentDetails) { agent in + portfolioViewModel.toggleAgent(agent) + } + + // Assets Section + AssetsSectionView(assets: portfolioViewModel.assets) + + // Spacer for tab bar + Spacer().frame(height: 80) + } + .padding(.horizontal, 20) + } + .background(Color.pocketBackground.ignoresSafeArea()) + .refreshable { + portfolioViewModel.refreshPortfolio() + } + } +} + +// MARK: - Home Header +struct HomeHeaderView: View { + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("POCKET") + .font(.system(size: 24, weight: .black, design: .rounded)) + .foregroundColor(.white) + .tracking(1) + + Text("Your Crypto AI Assistant") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + + Spacer() + + // Notification Bell + Button(action: { + // TODO: Show notifications + }, label: { + Image(systemName: "bell.fill") + .font(.system(size: 18)) + .foregroundColor(.pocketSecondaryText) + .frame(width: 40, height: 40) + .background(Circle().fill(Color.pocketCardBackground)) + }) + } + .padding(.top, 16) + } +} + +// MARK: - Balance Card +struct BalanceCardView: View { + let portfolio: PortfolioData + + var body: some View { + VStack(spacing: 12) { + Text("Total Balance") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + + Text("$\(String(format: "%.2f", portfolio.balance))") + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundColor(.white) + + HStack(spacing: 6) { + Image(systemName: portfolio.isDown ? "arrow.down.right" : "arrow.up.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(portfolio.isDown ? .pocketRed : .pocketGreen) + + Text("\(String(format: "%.1f", portfolio.changePercent))%") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(portfolio.isDown ? .pocketRed : .pocketGreen) + + Text("today") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color.pocketCardBackground) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.pocketCardBorder, lineWidth: 1) + ) + ) + } +} + +// MARK: - Quick Actions +struct QuickActionsView: View { + var body: some View { + HStack(spacing: 12) { + QuickActionButton(icon: "arrow.down", title: "Receive") + QuickActionButton(icon: "arrow.up", title: "Send") + QuickActionButton(icon: "arrow.left.arrow.right", title: "Swap") + QuickActionButton(icon: "plus", title: "Buy") + } + } +} + +struct QuickActionButton: View { + let icon: String + let title: String + + var body: some View { + Button(action: { + // TODO: Implement action + }, label: { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.pocketCyan) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(Color.pocketCyan.opacity(0.12)) + ) + + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + .frame(maxWidth: .infinity) + }) + } +} + +// MARK: - Agents Section +struct AgentsSectionView: View { + let agentDetails: [AgentDetail] + let onToggle: (AgentDetail) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("AI Agents") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + + Spacer() + + Text("See all") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.pocketCyan) + } + + ForEach(agentDetails) { detail in + AgentRowView(agentDetail: detail, onToggle: { onToggle(detail) }) + } + } + } +} + +struct AgentRowView: View { + let agentDetail: AgentDetail + let onToggle: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Agent Color Circle + Circle() + .fill(agentDetail.agent.color) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: agentDetail.agent.icon) + .font(.system(size: 16)) + .foregroundColor(.white) + ) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(agentDetail.agent.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + HStack(spacing: 4) { + Circle() + .fill(agentDetail.status.color) + .frame(width: 6, height: 6) + + Text(agentDetail.status.rawValue) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(agentDetail.status.color) + } + } + + Text(agentDetail.description) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.pocketSecondaryText) + .lineLimit(1) + } + + Spacer() + + // P/L + VStack(alignment: .trailing, spacing: 2) { + Text(agentDetail.profitLoss >= 0 ? "+\(String(format: "%.1f", agentDetail.profitLoss))%" : "\(String(format: "%.1f", agentDetail.profitLoss))%") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(agentDetail.profitLoss >= 0 ? .pocketGreen : .pocketRed) + + Button(action: onToggle, label: { + Text(agentDetail.status == .running ? "Pause" : "Start") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.pocketCyan) + }) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + } +} + +// MARK: - Assets Section +struct AssetsSectionView: View { + let assets: [Asset] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Assets") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + + Spacer() + + Text("See all") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.pocketCyan) + } + + if assets.isEmpty { + EmptyAssetsView() + } else { + ForEach(assets) { asset in + AssetRowView(asset: asset) + } + } + } + } +} + +struct AssetRowView: View { + let asset: Asset + + var body: some View { + HStack(spacing: 12) { + // Token Icon + Circle() + .fill(asset.chainType == .ethereum ? Color.agentPredictions : Color.agentDefi) + .frame(width: 40, height: 40) + .overlay( + Text(String(asset.symbol.prefix(1))) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(asset.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + Text("\(String(format: "%.4f", asset.balance)) \(asset.symbol)") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.pocketSecondaryText) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text("$\(String(format: "%.2f", asset.value))") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + HStack(spacing: 3) { + Image(systemName: asset.isUp ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 10, weight: .semibold)) + + Text("\(String(format: "%.1f", asset.changePercent))%") + .font(.system(size: 12, weight: .medium)) + } + .foregroundColor(asset.isUp ? .pocketGreen : .pocketRed) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + } +} + +struct EmptyAssetsView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "wallet.pass") + .font(.system(size: 32)) + .foregroundColor(.pocketSecondaryText) + + Text("No assets yet") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + + Text("Your crypto assets will appear here\nonce you receive or buy tokens.") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.pocketTertiaryText) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + } +} + +#Preview { + HomeView( + portfolioViewModel: PortfolioViewModel(), + authViewModel: AuthViewModel() + ) + .preferredColorScheme(.dark) +} diff --git a/Pocket/Sources/Views/Main/MainTabView.swift b/Pocket/Sources/Views/Main/MainTabView.swift new file mode 100644 index 0000000..71a5bae --- /dev/null +++ b/Pocket/Sources/Views/Main/MainTabView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct MainTabView: View { + @ObservedObject var authViewModel: AuthViewModel + @StateObject private var portfolioViewModel = PortfolioViewModel() + @StateObject private var chatViewModel = ChatViewModel() + @StateObject private var marketViewModel = MarketViewModel() + @State private var selectedTab: AppTab = .home + + var body: some View { + ZStack(alignment: .bottom) { + // Tab Content + Group { + switch selectedTab { + case .home: + HomeView( + portfolioViewModel: portfolioViewModel, + authViewModel: authViewModel + ) + case .chat: + ChatView(chatViewModel: chatViewModel) + case .market: + MarketView(marketViewModel: marketViewModel) + case .settings: + SettingsView(authViewModel: authViewModel) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Custom Tab Bar + CustomTabBar(selectedTab: $selectedTab) + } + .ignoresSafeArea(.keyboard) + } +} + +// MARK: - Custom Tab Bar +struct CustomTabBar: View { + @Binding var selectedTab: AppTab + + var body: some View { + HStack(spacing: 0) { + ForEach(AppTab.allCases, id: \.rawValue) { tab in + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + }, label: { + VStack(spacing: 4) { + Image(systemName: tab.icon) + .font(.system(size: 20)) + .foregroundColor(selectedTab == tab ? .pocketCyan : .pocketSecondaryText) + + Text(tab.title) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(selectedTab == tab ? .pocketCyan : .pocketSecondaryText) + } + .frame(maxWidth: .infinity) + .padding(.top, 10) + .padding(.bottom, 6) + }) + } + } + .background( + Rectangle() + .fill(Color.pocketCardBackground) + .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: -4) + .ignoresSafeArea(edges: .bottom) + ) + } +} + +#Preview { + MainTabView(authViewModel: AuthViewModel()) + .preferredColorScheme(.dark) +} diff --git a/Pocket/Sources/Views/Main/MarketView.swift b/Pocket/Sources/Views/Main/MarketView.swift new file mode 100644 index 0000000..2df0487 --- /dev/null +++ b/Pocket/Sources/Views/Main/MarketView.swift @@ -0,0 +1,192 @@ +import SwiftUI + +struct MarketView: View { + @ObservedObject var marketViewModel: MarketViewModel + + var body: some View { + VStack(spacing: 0) { + // Header + MarketHeaderView(searchText: $marketViewModel.searchText) + + // Timeframe Selector + TimeframeSelectorView( + selectedTimeframe: marketViewModel.selectedTimeframe, + onSelect: { marketViewModel.selectTimeframe($0) } + ) + + // Market List + ScrollView { + LazyVStack(spacing: 12) { + ForEach(marketViewModel.filteredItems) { item in + MarketRowView(item: item) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 80) + } + } + .background(Color.pocketBackground.ignoresSafeArea()) + } +} + +// MARK: - Market Header +struct MarketHeaderView: View { + @Binding var searchText: String + + var body: some View { + VStack(spacing: 12) { + HStack { + Text("Market") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 16) + + // Search Bar + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15)) + .foregroundColor(.pocketSecondaryText) + + TextField("Search tokens...", text: $searchText) + .font(.system(size: 15)) + .foregroundColor(.white) + + if !searchText.isEmpty { + Button(action: { searchText = "" }, label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 15)) + .foregroundColor(.pocketSecondaryText) + }) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.pocketCardBackground) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.pocketCardBorder, lineWidth: 1) + ) + ) + .padding(.horizontal, 20) + } + } +} + +// MARK: - Timeframe Selector +struct TimeframeSelectorView: View { + let selectedTimeframe: MarketViewModel.MarketTimeframe + let onSelect: (MarketViewModel.MarketTimeframe) -> Void + + var body: some View { + HStack(spacing: 6) { + ForEach(MarketViewModel.MarketTimeframe.allCases, id: \.self) { timeframe in + Button(action: { onSelect(timeframe) }, label: { + Text(timeframe.rawValue) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(selectedTimeframe == timeframe ? .white : .pocketSecondaryText) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + Capsule() + .fill(selectedTimeframe == timeframe ? Color.pocketCyan.opacity(0.2) : Color.clear) + ) + }) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 8) + } +} + +// MARK: - Market Row +struct MarketRowView: View { + let item: MarketItem + + var body: some View { + HStack(spacing: 12) { + // Token Icon + Circle() + .fill(tokenColor(for: item.symbol)) + .frame(width: 40, height: 40) + .overlay( + Text(String(item.symbol.prefix(1))) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + ) + + // Name & Symbol + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + Text(item.symbol) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + + Spacer() + + // Mini Chart + MiniChartView( + dataPoints: item.chartPoints, + lineColor: item.isUp ? .pocketGreen : .pocketRed, + animated: false + ) + .frame(width: 60, height: 30) + + // Price & Change + VStack(alignment: .trailing, spacing: 4) { + Text(formatPrice(item.price)) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + + HStack(spacing: 3) { + Image(systemName: item.isUp ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 10, weight: .semibold)) + + Text("\(String(format: "%.1f", item.changePercent))%") + .font(.system(size: 12, weight: .medium)) + } + .foregroundColor(item.isUp ? .pocketGreen : .pocketRed) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + } + + private func tokenColor(for symbol: String) -> Color { + switch symbol { + case "BTC": return Color(red: 0.96, green: 0.62, blue: 0.13) + case "ETH": return Color.agentPredictions + case "SOL": return Color.agentDefi + case "LINK": return Color(red: 0.22, green: 0.42, blue: 0.92) + default: return Color.pocketSecondaryText + } + } + + private func formatPrice(_ price: Double) -> String { + if price >= 1000 { + return "$\(String(format: "%.0f", price))" + } else if price >= 1 { + return "$\(String(format: "%.2f", price))" + } else { + return "$\(String(format: "%.4f", price))" + } + } +} + +#Preview { + MarketView(marketViewModel: MarketViewModel()) + .preferredColorScheme(.dark) +} diff --git a/Pocket/Sources/Views/Main/SettingsView.swift b/Pocket/Sources/Views/Main/SettingsView.swift new file mode 100644 index 0000000..48d3445 --- /dev/null +++ b/Pocket/Sources/Views/Main/SettingsView.swift @@ -0,0 +1,265 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var authViewModel: AuthViewModel + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Header + HStack { + Text("Settings") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + + Spacer() + } + .padding(.top, 16) + + // Profile Section + ProfileSectionView() + + // Wallets Section + WalletsSectionView() + + // Preferences Section + PreferencesSectionView() + + // About Section + AboutSectionView() + + // Logout Button + Button(action: { + authViewModel.logout() + }, label: { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 16)) + Text("Log Out") + .font(.system(size: 16, weight: .semibold)) + } + .foregroundColor(.pocketRed) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketRed.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.pocketRed.opacity(0.3), lineWidth: 1) + ) + ) + }) + + Spacer().frame(height: 80) + } + .padding(.horizontal, 20) + } + .background(Color.pocketBackground.ignoresSafeArea()) + } +} + +// MARK: - Profile Section +struct ProfileSectionView: View { + var body: some View { + VStack(spacing: 0) { + SettingsGroupHeader(title: "Profile") + + HStack(spacing: 14) { + Circle() + .fill(Color.pocketCyan.opacity(0.2)) + .frame(width: 50, height: 50) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: 20)) + .foregroundColor(.pocketCyan) + ) + + VStack(alignment: .leading, spacing: 4) { + Text("Pocket User") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + + Text("Connected via Privy") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.pocketSecondaryText) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + } + } +} + +// MARK: - Wallets Section +struct WalletsSectionView: View { + var body: some View { + VStack(spacing: 0) { + SettingsGroupHeader(title: "Wallets") + + VStack(spacing: 1) { + SettingsRowView( + icon: "wallet.pass.fill", + iconColor: .pocketCyan, + title: "Embedded Wallets", + subtitle: "EVM + Solana" + ) + SettingsRowView( + icon: "link", + iconColor: .agentStocks, + title: "Connected Wallets", + subtitle: "None" + ) + SettingsRowView( + icon: "key.fill", + iconColor: .agentDefi, + title: "Export Private Key", + subtitle: nil + ) + } + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } +} + +// MARK: - Preferences Section +struct PreferencesSectionView: View { + var body: some View { + VStack(spacing: 0) { + SettingsGroupHeader(title: "Preferences") + + VStack(spacing: 1) { + SettingsRowView( + icon: "bell.fill", + iconColor: .agentPredictions, + title: "Notifications", + subtitle: "Enabled" + ) + SettingsRowView( + icon: "globe", + iconColor: .pocketCyan, + title: "Network", + subtitle: "Mainnet" + ) + SettingsRowView( + icon: "dollarsign.circle.fill", + iconColor: .pocketGreen, + title: "Currency", + subtitle: "USD" + ) + } + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } +} + +// MARK: - About Section +struct AboutSectionView: View { + var body: some View { + VStack(spacing: 0) { + SettingsGroupHeader(title: "About") + + VStack(spacing: 1) { + SettingsRowView( + icon: "doc.text.fill", + iconColor: .pocketSecondaryText, + title: "Privacy Policy", + subtitle: nil + ) + SettingsRowView( + icon: "doc.plaintext.fill", + iconColor: .pocketSecondaryText, + title: "Terms of Service", + subtitle: nil + ) + SettingsRowView( + icon: "info.circle.fill", + iconColor: .pocketSecondaryText, + title: "Version", + subtitle: "1.0.0" + ) + } + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.pocketCardBackground) + ) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } +} + +// MARK: - Reusable Components +struct SettingsGroupHeader: View { + let title: String + + var body: some View { + HStack { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.pocketSecondaryText) + .textCase(.uppercase) + Spacer() + } + .padding(.bottom, 8) + } +} + +struct SettingsRowView: View { + let icon: String + let iconColor: Color + let title: String + let subtitle: String? + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundColor(iconColor) + .frame(width: 32, height: 32) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(iconColor.opacity(0.12)) + ) + + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.white) + + Spacer() + + if let subtitle = subtitle { + Text(subtitle) + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.pocketSecondaryText) + } + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.pocketTertiaryText) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } +} + +#Preview { + SettingsView(authViewModel: AuthViewModel()) + .preferredColorScheme(.dark) +} diff --git a/Pocket/Sources/Views/WelcomeView.swift b/Pocket/Sources/Views/WelcomeView.swift new file mode 100644 index 0000000..3166692 --- /dev/null +++ b/Pocket/Sources/Views/WelcomeView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct WelcomeView: View { + @StateObject private var viewModel = WelcomeViewModel() + @ObservedObject var authViewModel: AuthViewModel + @State private var showContent = false + + var body: some View { + ZStack { + // Background + Color.pocketBackground + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + .frame(height: 20) + + // Logo Section + PocketLogoView() + .opacity(showContent ? 1 : 0) + .offset(y: showContent ? 0 : -20) + .animation(.easeOut(duration: 0.6).delay(0.2), value: showContent) + + Spacer() + .frame(height: 24) + + // Carousel Cards + CarouselCardContainer(viewModel: viewModel) + .opacity(showContent ? 1 : 0) + .scaleEffect(showContent ? 1 : 0.95) + .animation(.easeOut(duration: 0.6).delay(0.4), value: showContent) + + Spacer() + + // Bottom Section + VStack(spacing: 16) { + // Get Started Button + Button(action: { + authViewModel.showLogin() + }, label: { + Text("Get started") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background( + Capsule() + .fill(Color.pocketCyan) + ) + }) + .padding(.horizontal, 40) + .opacity(showContent ? 1 : 0) + .offset(y: showContent ? 0 : 20) + .animation(.easeOut(duration: 0.6).delay(0.6), value: showContent) + + // Terms & Conditions + VStack(spacing: 4) { + Text("By continuing you agree to our") + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.pocketSecondaryText) + + Button(action: { + // Handle terms action + }, label: { + Text("Terms & Conditions") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.pocketCyan) + .underline() + }) + } + .opacity(showContent ? 1 : 0) + .animation(.easeOut(duration: 0.6).delay(0.8), value: showContent) + } + .padding(.bottom, 24) + } + } + .onAppear { + showContent = true + } + .overlay { + if authViewModel.showLoginSheet { + LoginSheetView(authViewModel: authViewModel) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.4, dampingFraction: 0.85), value: authViewModel.showLoginSheet) + } + } + } +} + +#Preview { + WelcomeView(authViewModel: AuthViewModel()) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c23955 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Pocket - Your Crypto AI Assistant + +Crypto AIアシスタントiOSアプリ「POCKET」のSwiftUIプロジェクトです。 + +## 概要 + +POCKETは、AIエージェントが株式・暗号通貨の取引を自動化するコンセプトのiOSアプリです。洗練されたダークテーマのUIと、カルーセル形式のオンボーディング画面を備えています。 + +## スクリーンショット + +| ポートフォリオ | チャット | 株式チャート | +|:---:|:---:|:---:| +| ![Portfolio Card](screenshots/01_portfolio_card.png) | ![Chat Card](screenshots/02_chat_card.png) | ![Stock Chart Card](screenshots/03_stock_chart_card.png) | + +## 機能 + +| 機能 | 説明 | +|------|------| +| ポートフォリオ表示 | 残高と変動率をリアルタイムで表示 | +| AIエージェント | Stocks、Predictions、Defiの3種類のAIエージェント | +| チャットインターフェース | エージェントとの対話形式の取引通知 | +| 株式チャート | ミニ折れ線チャートによる価格推移表示 | +| カルーセルUI | 自動スクロール付きのカード切り替え | +| アニメーション | フェードイン・スライドインの滑らかなアニメーション | + +## 技術スタック + +| 項目 | 詳細 | +|------|------| +| 言語 | Swift 5.9+ | +| フレームワーク | SwiftUI | +| 最小対応OS | iOS 16.0 | +| アーキテクチャ | MVVM | +| デザインパターン | Combine, ObservableObject | + +## プロジェクト構造 + +``` +PocketApp/ +├── Package.swift +├── README.md +├── screenshots/ +│ ├── 01_portfolio_card.png +│ ├── 02_chat_card.png +│ └── 03_stock_chart_card.png +└── Pocket/ + └── Sources/ + ├── PocketApp.swift + ├── Models/ + ├── ViewModels/ + ├── Views/ + ├── Components/ + └── Extensions/ +``` + +## セットアップ方法 + +### Xcodeプロジェクトとして使用する場合 + +1. Xcodeで新しいiOSプロジェクトを作成します(Interface: SwiftUI, Language: Swift) +2. 既存のContentView.swiftを削除します +3. `Pocket/Sources/` 内の全ファイルをプロジェクトにドラッグ&ドロップします +4. `PocketApp.swift` がアプリのエントリーポイントとして設定されていることを確認します +5. ビルドターゲットをiOS 16.0以上に設定します +6. ビルドして実行します + +### Swift Package として使用する場合 + +1. このディレクトリをXcodeで開きます +2. `Package.swift` が自動的に認識されます +3. ビルドして実行します + +## デザイン仕様 + +### カラーパレット + +| 色名 | 用途 | HEX値 | +|------|------|-------| +| Pocket Cyan | アクセントカラー、ボタン | #00D9F2 | +| Background | 背景色 | #000000 | +| Card Background | カード背景 | #1F1F24 | +| Agent Stocks | Stocksエージェント | #C7A64D | +| Agent Predictions | Predictionsエージェント | #4073E6 | +| Agent Defi | Defiエージェント | #9973CC | +| Pocket Red | 下落表示、TSLAカラー | #E63333 | + +### アニメーション + +アプリ起動時にコンテンツが段階的にフェードインし、カルーセルカードは4秒間隔で自動スクロールします。ユーザーが手動でスワイプした場合は、8秒後に自動スクロールが再開されます。 + +## ライセンス + +このプロジェクトはデモンストレーション目的で作成されています。 diff --git a/pocket-pwa/components.json b/pocket-pwa/components.json new file mode 100644 index 0000000..78cd18f --- /dev/null +++ b/pocket-pwa/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/pocket-pwa/index.html b/pocket-pwa/index.html new file mode 100644 index 0000000..662db8c --- /dev/null +++ b/pocket-pwa/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + Pocket - Your Crypto AI Assistant + + + +
+ + + diff --git a/pocket-pwa/package.json b/pocket-pwa/package.json new file mode 100644 index 0000000..0861b0f --- /dev/null +++ b/pocket-pwa/package.json @@ -0,0 +1,42 @@ +{ + "name": "pocket-pwa", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@privy-io/react-auth": "^3.17.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.364.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.12.4", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7", + "vite-plugin-pwa": "^1.2.0", + "workbox-window": "^7.4.0" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/node": "^25.5.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.12.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "~5.6.2", + "typescript-eslint": "^8.15.0", + "vite": "^6.0.1" + } +} diff --git a/pocket-pwa/postcss.config.js b/pocket-pwa/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/pocket-pwa/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/pocket-pwa/public/manifest.json b/pocket-pwa/public/manifest.json new file mode 100644 index 0000000..035e6c7 --- /dev/null +++ b/pocket-pwa/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Pocket - Your Crypto AI Assistant", + "short_name": "Pocket", + "description": "Your Crypto AI Assistant with embedded wallets and AI agents", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "portrait", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/pocket-pwa/public/vite.svg b/pocket-pwa/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/pocket-pwa/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pocket-pwa/src/App.css b/pocket-pwa/src/App.css new file mode 100644 index 0000000..2ae0672 --- /dev/null +++ b/pocket-pwa/src/App.css @@ -0,0 +1,40 @@ +#root { + max-width: none; + width: 100%; + margin: 0; + padding: 0; + text-align: left; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } +} + +.animate-fade-in { animation: fadeIn 0.6s ease-out forwards; } +.animate-fade-in-delay-1 { animation: fadeIn 0.6s ease-out 0.2s forwards; opacity: 0; } +.animate-fade-in-delay-2 { animation: fadeIn 0.6s ease-out 0.4s forwards; opacity: 0; } +.animate-fade-in-delay-3 { animation: fadeIn 0.6s ease-out 0.6s forwards; opacity: 0; } +.animate-slide-up { animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; } + +.dot-pulse-1 { animation: pulse 1.2s ease-in-out infinite 0s; } +.dot-pulse-2 { animation: pulse 1.2s ease-in-out infinite 0.4s; } +.dot-pulse-3 { animation: pulse 1.2s ease-in-out infinite 0.8s; } + +.no-scrollbar::-webkit-scrollbar { display: none; } +.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + +html, body { + overscroll-behavior: none; + -webkit-tap-highlight-color: transparent; +} diff --git a/pocket-pwa/src/App.tsx b/pocket-pwa/src/App.tsx new file mode 100644 index 0000000..2af4590 --- /dev/null +++ b/pocket-pwa/src/App.tsx @@ -0,0 +1,37 @@ +import './App.css'; +import { useAuth } from './hooks/useAuth'; +import { WelcomeView } from './components/WelcomeView'; +import { LoginSheet } from './components/auth/LoginSheet'; +import { MainTabView } from './components/main/MainTabView'; + +function App() { + const auth = useAuth(); + + if (auth.authState === 'authenticated') { + return ; + } + + return ( + <> + + {auth.showLoginSheet && ( + { + auth.setEmailInput(email); + auth.validateEmail(email); + }} + onDismiss={auth.dismissLogin} + onMockLogin={auth.mockLogin} + onNavigateToOtherSocials={auth.navigateToOtherSocials} + onNavigateToWalletSelection={auth.navigateToWalletSelection} + onNavigateBack={auth.navigateBack} + /> + )} + + ); +} + +export default App; diff --git a/pocket-pwa/src/assets/react.svg b/pocket-pwa/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/pocket-pwa/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pocket-pwa/src/components/WelcomeView.tsx b/pocket-pwa/src/components/WelcomeView.tsx new file mode 100644 index 0000000..536c7e3 --- /dev/null +++ b/pocket-pwa/src/components/WelcomeView.tsx @@ -0,0 +1,185 @@ +import { useState, useEffect, useCallback } from 'react'; +import { PocketLogo } from './common/PocketLogo'; +import { MiniChart } from './common/MiniChart'; +import { SAMPLE_STOCK, AGENTS } from '../data/sampleData'; +import type { CarouselCardType } from '../types'; + +const CARD_TYPES: CarouselCardType[] = ['portfolio', 'chat', 'stockChart']; + +interface WelcomeViewProps { + onGetStarted: () => void; +} + +export function WelcomeView({ onGetStarted }: WelcomeViewProps) { + const [currentCard, setCurrentCard] = useState(0); + const [showContent, setShowContent] = useState(false); + + useEffect(() => { setShowContent(true); }, []); + + useEffect(() => { + const timer = setInterval(() => { + setCurrentCard(prev => (prev + 1) % CARD_TYPES.length); + }, 4000); + return () => clearInterval(timer); + }, []); + + return ( +
+
+ {/* Logo */} +
+ +
+ +
+ + {/* Carousel */} +
+ + {/* Page Dots */} +
+ {CARD_TYPES.map((_, i) => ( +
+
+ +
+ + {/* Get Started Button */} +
+ +
+ + {/* Terms */} +
+

By continuing you agree to our

+ +
+
+
+ ); +} + +function CarouselCards({ currentCard }: { currentCard: number }) { + const renderCard = useCallback((type: CarouselCardType) => { + switch (type) { + case 'portfolio': return ; + case 'chat': return ; + case 'stockChart': return ; + } + }, []); + + return ( +
+ {CARD_TYPES.map((type, i) => ( +
+ {renderCard(type)} +
+ ))} +
+ ); +} + +function PortfolioCard() { + return ( +
+

Total Balance

+

$62.18

+
+ + 6.1% + + today +
+
+ {AGENTS.map(agent => ( +
+
+ {agent.name[0]} +
+ {agent.name} +
+
+ Running +
+
+ ))} +
+
+ ); +} + +function ChatCard() { + return ( +
+
+
+
+

Stocks Trader

+

3:42 pm

+
+
+

+ TSLA hit my entry range. Looking at a good setup here. +

+
+

Nice. What did you do?

+
+
+
+
+

Stocks Trader

+

3:45 pm

+
+
+

Executed a buy at the support level.

+
+
+ T +
+ Bought 24.3 TSLA +
+
+ ); +} + +function StockChartCard() { + return ( +
+
+
+ T +
+ {SAMPLE_STOCK.ticker} +
+

${SAMPLE_STOCK.price}

+
+ +
+
+
+ Stocks Agent Running +
+
+ ); +} diff --git a/pocket-pwa/src/components/auth/LoginSheet.tsx b/pocket-pwa/src/components/auth/LoginSheet.tsx new file mode 100644 index 0000000..813df0d --- /dev/null +++ b/pocket-pwa/src/components/auth/LoginSheet.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import { X, ArrowLeft, Search, Mail, Globe, MessageSquare, Send, Wallet, Diamond, Hexagon, LayoutGrid } from 'lucide-react'; +import type { LoginPage, WalletOption } from '../../types'; +import { WALLET_OPTIONS, WALLET_ICON_COLORS } from '../../data/sampleData'; + +interface LoginSheetProps { + currentPage: LoginPage; + emailInput: string; + isEmailValid: boolean; + onEmailChange: (email: string) => void; + onDismiss: () => void; + onMockLogin: () => void; + onNavigateToOtherSocials: () => void; + onNavigateToWalletSelection: () => void; + onNavigateBack: () => void; +} + +export function LoginSheet({ + currentPage, emailInput, isEmailValid, + onEmailChange, onDismiss, onMockLogin, + onNavigateToOtherSocials, onNavigateToWalletSelection, onNavigateBack, +}: LoginSheetProps) { + return ( +
+
+
+
+ {currentPage === 'main' && ( + + )} + {currentPage === 'otherSocials' && ( + + )} + {currentPage === 'walletSelection' && ( + + )} + +
+
+
+ ); +} + +interface MainLoginContentProps { + emailInput: string; + isEmailValid: boolean; + onEmailChange: (email: string) => void; + onDismiss: () => void; + onMockLogin: () => void; + onNavigateToOtherSocials: () => void; + onNavigateToWalletSelection: () => void; +} + +function MainLoginContent({ + emailInput, isEmailValid, onEmailChange, + onDismiss, onMockLogin, onNavigateToOtherSocials, onNavigateToWalletSelection, +}: MainLoginContentProps) { + return ( +
+
+ +
+
+

POCKET

+

Log in or sign up

+
+
+ } iconBg="#E08F33" title="MetaMask" showLastUsed showChainIcon onClick={onMockLogin} /> +
+
+ +
+ onEmailChange(e.target.value)} + className="flex-1 bg-transparent text-white text-base outline-none placeholder-gray-500" /> + {isEmailValid && ( + + )} +
+ G} iconBg="#383A3F" title="Google" isHighlighted onClick={onMockLogin} /> + } iconBg="#383A3F" title="Other socials" onClick={onNavigateToOtherSocials} /> + } iconBg="#383A3F" title="Continue with a wallet" onClick={onNavigateToWalletSelection} /> +
+
+ ); +} + +function OtherSocialsContent({ onDismiss, onBack, onLogin }: { onDismiss: () => void; onBack: () => void; onLogin: () => void }) { + return ( +
+
+ + +
+

Log in or sign up

+
+ } iconBg="#FFFFFF" title="Twitter" onClick={onLogin} /> + } iconBg="#5766F2" title="Discord" onClick={onLogin} /> + } iconBg="#66ABE0" title="Telegram" onClick={onLogin} /> +
+
+ ); +} + +function WalletSelectionContent({ onDismiss, onBack, onSelectWallet }: { onDismiss: () => void; onBack: () => void; onSelectWallet: () => void }) { + const [searchText, setSearchText] = useState(''); + const filtered = searchText ? WALLET_OPTIONS.filter(w => w.name.toLowerCase().includes(searchText.toLowerCase())) : WALLET_OPTIONS; + + return ( +
+
+ + +
+
+
+ +
+
+

Select your wallet

+
+
+ +
+ setSearchText(e.target.value)} + className="flex-1 bg-transparent text-white text-sm outline-none placeholder-gray-500" /> +
+
+
+ {filtered.map(wallet => ())} +
+
+
+ ); +} + +function LoginOptionButton({ icon, iconBg, title, showLastUsed, showChainIcon, isHighlighted, onClick }: { + icon: React.ReactNode; iconBg: string; title: string; showLastUsed?: boolean; showChainIcon?: boolean; isHighlighted?: boolean; onClick: () => void; +}) { + return ( + + ); +} + +function SocialButton({ icon, iconBg, title, onClick }: { icon: React.ReactNode; iconBg: string; title: string; onClick: () => void }) { + return ( + + ); +} + +function WalletRow({ wallet, onClick }: { wallet: WalletOption; onClick: () => void }) { + const color = WALLET_ICON_COLORS[wallet.name] || '#383A3F'; + return ( + + ); +} + +function LoginFooter() { + return ( +
+

+ By continuing, you agree to Pocket's{' '} + Terms of Service + {' '}and{' '} + Privacy Policy +

+
+ ); +} diff --git a/pocket-pwa/src/components/common/MiniChart.tsx b/pocket-pwa/src/components/common/MiniChart.tsx new file mode 100644 index 0000000..d7c02ca --- /dev/null +++ b/pocket-pwa/src/components/common/MiniChart.tsx @@ -0,0 +1,53 @@ +import { useRef, useEffect } from 'react'; + +interface MiniChartProps { + dataPoints: number[]; + lineColor: string; + width?: number; + height?: number; +} + +export function MiniChart({ dataPoints, lineColor, width = 60, height = 30 }: MiniChartProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || dataPoints.length < 2) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + + const min = Math.min(...dataPoints); + const max = Math.max(...dataPoints); + const range = max - min || 1; + + ctx.beginPath(); + ctx.strokeStyle = lineColor; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + + dataPoints.forEach((point, i) => { + const x = (width / (dataPoints.length - 1)) * i; + const y = height * (1 - (point - min) / range); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + // End dot + const lastX = width; + const lastY = height * (1 - (dataPoints[dataPoints.length - 1] - min) / range); + ctx.beginPath(); + ctx.fillStyle = lineColor; + ctx.arc(lastX - 1, lastY, 3, 0, Math.PI * 2); + ctx.fill(); + }, [dataPoints, lineColor, width, height]); + + return ; +} diff --git a/pocket-pwa/src/components/common/PocketLogo.tsx b/pocket-pwa/src/components/common/PocketLogo.tsx new file mode 100644 index 0000000..e46fabb --- /dev/null +++ b/pocket-pwa/src/components/common/PocketLogo.tsx @@ -0,0 +1,12 @@ +export function PocketLogo() { + return ( +
+

+ POCKET +

+

+ Your Crypto AI assistant +

+
+ ); +} diff --git a/pocket-pwa/src/components/main/ChatView.tsx b/pocket-pwa/src/components/main/ChatView.tsx new file mode 100644 index 0000000..d41e009 --- /dev/null +++ b/pocket-pwa/src/components/main/ChatView.tsx @@ -0,0 +1,119 @@ +import { useRef, useEffect } from 'react'; +import { ArrowUpCircle } from 'lucide-react'; +import type { ChatMessage, Agent } from '../../types'; +import { AGENTS } from '../../data/sampleData'; +import { useChat } from '../../hooks/useChat'; + +export function ChatView() { + const { messages, inputText, setInputText, selectedAgent, isSending, sendMessage, selectAgent } = useChat(); + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages.length]); + + return ( +
+ {/* Header */} +
+

Chat

+
+ {AGENTS.map(agent => ( + selectAgent(agent)} /> + ))} +
+
+ + {/* Messages */} +
+
+ {messages.map(msg => ())} + {isSending && } +
+
+
+ + {/* Input */} +
+
+ setInputText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') sendMessage(); }} + className="flex-1 bg-transparent text-white text-sm outline-none placeholder-gray-500" /> +
+ +
+
+ ); +} + +function AgentChip({ agent, isSelected, onTap }: { agent: Agent; isSelected: boolean; onTap: () => void }) { + return ( + + ); +} + +function ChatBubble({ message }: { message: ChatMessage }) { + if (message.sender.type === 'user') { + return ( +
+
+

{message.text}

+
+
+ ); + } + + const agentName = message.sender.name; + return ( +
+
+
+
+
+ {agentName} + {message.time} +
+

{message.text}

+
+
+ {message.tradeBadge && ( +
+
+ T +
+ + {message.tradeBadge.action} {message.tradeBadge.amount} {message.tradeBadge.ticker} + +
+ )} +
+ ); +} + +function TypingIndicator({ agentName }: { agentName: string }) { + return ( +
+
+
+ {agentName} +
+
+
+
+
+
+
+ ); +} diff --git a/pocket-pwa/src/components/main/HomeView.tsx b/pocket-pwa/src/components/main/HomeView.tsx new file mode 100644 index 0000000..cc70402 --- /dev/null +++ b/pocket-pwa/src/components/main/HomeView.tsx @@ -0,0 +1,156 @@ +import { ArrowDownRight, ArrowUpRight, ArrowDown, ArrowUp, ArrowLeftRight, Plus, Bell } from 'lucide-react'; +import type { AgentDetail, Asset } from '../../types'; +import { AGENT_STATUS_COLORS } from '../../data/sampleData'; +import { usePortfolio } from '../../hooks/usePortfolio'; + +export function HomeView() { + const { portfolio, assets, agentDetails, toggleAgent } = usePortfolio(); + + return ( +
+
+ {/* Header */} +
+
+

POCKET

+

Your Crypto AI Assistant

+
+ +
+ + {/* Balance Card */} +
+

Total Balance

+

${portfolio.balance.toFixed(2)}

+
+ {portfolio.isDown + ? + : } + + {portfolio.changePercent.toFixed(1)}% + + today +
+
+ + {/* Quick Actions */} +
+ {[ + { icon: ArrowDown, title: 'Receive' }, + { icon: ArrowUp, title: 'Send' }, + { icon: ArrowLeftRight, title: 'Swap' }, + { icon: Plus, title: 'Buy' }, + ].map(action => ( + + ))} +
+ + {/* AI Agents */} +
+
+

AI Agents

+ +
+
+ {agentDetails.map(detail => ( + toggleAgent(detail.id)} /> + ))} +
+
+ + {/* Assets */} +
+
+

Assets

+ +
+ {assets.length === 0 ? ( + + ) : ( +
+ {assets.map(asset => ())} +
+ )} +
+
+
+ ); +} + +function AgentRow({ detail, onToggle }: { detail: AgentDetail; onToggle: () => void }) { + const statusColor = AGENT_STATUS_COLORS[detail.status] || '#8C8C99'; + return ( +
+
+ {detail.agent.name[0]} +
+
+
+ {detail.agent.name} +
+
+ {detail.status} +
+
+

{detail.description}

+
+
+ = 0 ? '#33CC66' : '#E63333' }}> + {detail.profitLoss >= 0 ? '+' : ''}{detail.profitLoss.toFixed(1)}% + + +
+
+ ); +} + +function AssetRow({ asset }: { asset: Asset }) { + const iconColor = asset.chainType === 'ethereum' ? '#4073E6' : '#9973CC'; + return ( +
+
+ {asset.symbol[0]} +
+
+

{asset.name}

+

{asset.balance.toFixed(4)} {asset.symbol}

+
+
+ ${asset.value.toFixed(2)} +
+ {asset.isUp ? : } + {asset.changePercent.toFixed(1)}% +
+
+
+ ); +} + +function EmptyAssets() { + return ( +
+ +

No assets yet

+

+ Your crypto assets will appear here
once you receive or buy tokens. +

+
+ ); +} + +function Wallet({ size, style }: { size: number; style: React.CSSProperties }) { + return ( + + + + ); +} diff --git a/pocket-pwa/src/components/main/MainTabView.tsx b/pocket-pwa/src/components/main/MainTabView.tsx new file mode 100644 index 0000000..86f178d --- /dev/null +++ b/pocket-pwa/src/components/main/MainTabView.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Home, MessageSquare, TrendingUp, Settings, type LucideIcon } from 'lucide-react'; +import type { AppTab } from '../../types'; +import { HomeView } from './HomeView'; +import { ChatView } from './ChatView'; +import { MarketView } from './MarketView'; +import { SettingsView } from './SettingsView'; + +interface MainTabViewProps { + onLogout: () => void; +} + +const TABS: { key: AppTab; title: string; icon: LucideIcon }[] = [ + { key: 'home', title: 'Home', icon: Home }, + { key: 'chat', title: 'Chat', icon: MessageSquare }, + { key: 'market', title: 'Market', icon: TrendingUp }, + { key: 'settings', title: 'Settings', icon: Settings }, +]; + +export function MainTabView({ onLogout }: MainTabViewProps) { + const [selectedTab, setSelectedTab] = useState('home'); + + return ( +
+ {/* Tab Content */} +
+ {selectedTab === 'home' && } + {selectedTab === 'chat' && } + {selectedTab === 'market' && } + {selectedTab === 'settings' && } +
+ + {/* Custom Tab Bar */} +
+ {TABS.map(tab => ( + + ))} + {/* Safe area padding for mobile */} +
+
+
+ ); +} diff --git a/pocket-pwa/src/components/main/MarketView.tsx b/pocket-pwa/src/components/main/MarketView.tsx new file mode 100644 index 0000000..30a7405 --- /dev/null +++ b/pocket-pwa/src/components/main/MarketView.tsx @@ -0,0 +1,85 @@ +import { Search, X, ArrowUpRight, ArrowDownRight } from 'lucide-react'; +import type { MarketItem, MarketTimeframe } from '../../types'; +import { MiniChart } from '../common/MiniChart'; +import { TOKEN_COLORS } from '../../data/sampleData'; +import { useMarket } from '../../hooks/useMarket'; + +const TIMEFRAMES: MarketTimeframe[] = ['1H', '1D', '1W', '1M', '1Y']; + +export function MarketView() { + const { filteredItems, searchText, setSearchText, selectedTimeframe, selectTimeframe } = useMarket(); + + return ( +
+ {/* Header */} +
+

Market

+ {/* Search */} +
+ + setSearchText(e.target.value)} + className="flex-1 bg-transparent text-white text-sm outline-none placeholder-gray-500" /> + {searchText && ( + + )} +
+
+ + {/* Timeframe Selector */} +
+ {TIMEFRAMES.map(tf => ( + + ))} +
+ + {/* Market List */} +
+
+ {filteredItems.map(item => ())} +
+
+
+ ); +} + +function MarketRow({ item }: { item: MarketItem }) { + const tokenColor = TOKEN_COLORS[item.symbol] || '#8C8C99'; + + const formatPrice = (price: number) => { + if (price >= 1000) return `$${price.toFixed(0)}`; + if (price >= 1) return `$${price.toFixed(2)}`; + return `$${price.toFixed(4)}`; + }; + + return ( +
+
+ {item.symbol[0]} +
+
+

{item.name}

+

{item.symbol}

+
+
+ +
+
+ {formatPrice(item.price)} +
+ {item.isUp ? : } + {item.changePercent.toFixed(1)}% +
+
+
+ ); +} diff --git a/pocket-pwa/src/components/main/SettingsView.tsx b/pocket-pwa/src/components/main/SettingsView.tsx new file mode 100644 index 0000000..845fd3f --- /dev/null +++ b/pocket-pwa/src/components/main/SettingsView.tsx @@ -0,0 +1,92 @@ +import { ChevronRight, User, Wallet, Link, Key, Bell, Globe, DollarSign, FileText, Info, LogOut, type LucideIcon } from 'lucide-react'; + +interface SettingsViewProps { + onLogout: () => void; +} + +export function SettingsView({ onLogout }: SettingsViewProps) { + return ( +
+
+ {/* Header */} +
+

Settings

+
+ + {/* Profile */} + +
+
+ +
+
+

Pocket User

+

Connected via Privy

+
+ +
+
+ + {/* Wallets */} + +
+ + + +
+
+ + {/* Preferences */} + +
+ + + +
+
+ + {/* About */} + +
+ + + +
+
+ + {/* Logout */} + +
+
+ ); +} + +function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function SettingsRow({ icon: Icon, iconColor, title, subtitle }: { + icon: LucideIcon; + iconColor: string; title: string; subtitle?: string; +}) { + return ( +
+
+ +
+ {title} + {subtitle && {subtitle}} + +
+ ); +} diff --git a/pocket-pwa/src/data/sampleData.ts b/pocket-pwa/src/data/sampleData.ts new file mode 100644 index 0000000..7e38594 --- /dev/null +++ b/pocket-pwa/src/data/sampleData.ts @@ -0,0 +1,74 @@ +import type { Agent, Asset, AgentDetail, MarketItem, WalletOption, ChatMessage, PortfolioData, StockData } from '../types'; + +export const AGENTS: Agent[] = [ + { id: '1', name: 'Stocks', color: '#C7A64D', icon: 'TrendingUp' }, + { id: '2', name: 'Predictions', color: '#4073E6', icon: 'Brain' }, + { id: '3', name: 'Defi', color: '#9973CC', icon: 'Coins' }, +]; + +export const SAMPLE_PORTFOLIO: PortfolioData = { + balance: 62.18, + changePercent: 6.1, + isDown: true, +}; + +export const SAMPLE_ASSETS: Asset[] = [ + { id: '1', name: 'Ethereum', symbol: 'ETH', balance: 0.0185, value: 48.62, changePercent: 2.3, isUp: true, chainType: 'ethereum' }, + { id: '2', name: 'Solana', symbol: 'SOL', balance: 0.082, value: 13.56, changePercent: 5.7, isUp: false, chainType: 'solana' }, +]; + +export const SAMPLE_AGENT_DETAILS: AgentDetail[] = [ + { id: '1', agent: AGENTS[0], status: 'Running', description: 'Analyzing stock markets and executing trades', profitLoss: 12.5 }, + { id: '2', agent: AGENTS[1], status: 'Running', description: 'Predicting market trends with AI models', profitLoss: -3.2 }, + { id: '3', agent: AGENTS[2], status: 'Paused', description: 'Managing DeFi positions and yield farming', profitLoss: 8.7 }, +]; + +export const SAMPLE_MARKET_ITEMS: MarketItem[] = [ + { id: '1', name: 'Bitcoin', symbol: 'BTC', price: 67432.50, changePercent: 1.8, isUp: true, chartPoints: [0.4, 0.42, 0.45, 0.43, 0.48, 0.50, 0.52, 0.55, 0.53, 0.58] }, + { id: '2', name: 'Ethereum', symbol: 'ETH', price: 2628.30, changePercent: 2.3, isUp: true, chartPoints: [0.5, 0.48, 0.52, 0.55, 0.53, 0.58, 0.60, 0.57, 0.62, 0.65] }, + { id: '3', name: 'Solana', symbol: 'SOL', price: 165.42, changePercent: 5.7, isUp: false, chartPoints: [0.7, 0.68, 0.65, 0.63, 0.60, 0.58, 0.55, 0.52, 0.50, 0.48] }, + { id: '4', name: 'Chainlink', symbol: 'LINK', price: 14.85, changePercent: 3.1, isUp: true, chartPoints: [0.3, 0.35, 0.38, 0.40, 0.42, 0.45, 0.48, 0.50, 0.52, 0.55] }, + { id: '5', name: 'Uniswap', symbol: 'UNI', price: 7.23, changePercent: 1.2, isUp: false, chartPoints: [0.6, 0.58, 0.55, 0.57, 0.54, 0.52, 0.50, 0.48, 0.50, 0.47] }, +]; + +export const WALLET_OPTIONS: WalletOption[] = [ + { id: '1', name: 'MetaMask', chains: ['ethereum', 'multichain'] }, + { id: '2', name: 'Coinbase Wallet', chains: ['ethereum', 'solana'] }, + { id: '3', name: 'Rainbow', chains: ['ethereum'] }, + { id: '4', name: 'Base', chains: ['ethereum'] }, + { id: '5', name: '1inch', chains: ['ethereum', 'multichain'] }, +]; + +export const SAMPLE_MESSAGES: ChatMessage[] = [ + { id: '1', sender: { type: 'agent', name: 'Stocks Trader' }, text: 'TSLA hit my entry range. Looking at a good setup here.', time: '3:42 pm' }, + { id: '2', sender: { type: 'user' }, text: 'Nice. What did you do?', time: '3:43 pm' }, + { id: '3', sender: { type: 'agent', name: 'Stocks Trader' }, text: 'Executed a buy at the support level.', time: '3:45 pm', tradeBadge: { id: '1', action: 'Bought', amount: 24.3, ticker: 'TSLA' } }, +]; + +export const SAMPLE_STOCK: StockData = { + id: '1', + ticker: 'TSLA', + price: 569.8, + chartPoints: [0.5, 0.45, 0.55, 0.40, 0.48, 0.42, 0.50, 0.45, 0.52, 0.48, 0.55, 0.50, 0.58, 0.62, 0.65, 0.70], + color: '#E63333', +}; + +export const AGENT_STATUS_COLORS: Record = { + Running: '#33CC66', + Paused: '#C7A64D', + Stopped: '#E63333', +}; + +export const TOKEN_COLORS: Record = { + BTC: '#F59E0B', + ETH: '#4073E6', + SOL: '#9973CC', + LINK: '#375DEB', +}; + +export const WALLET_ICON_COLORS: Record = { + MetaMask: '#E08F33', + 'Coinbase Wallet': '#3366F2', + Rainbow: '#F28033', + Base: '#3355FF', +}; diff --git a/pocket-pwa/src/hooks/useAuth.ts b/pocket-pwa/src/hooks/useAuth.ts new file mode 100644 index 0000000..ab616d0 --- /dev/null +++ b/pocket-pwa/src/hooks/useAuth.ts @@ -0,0 +1,54 @@ +import { useState, useCallback } from 'react'; +import type { AuthState, LoginPage } from '../types'; + +export function useAuth() { + const [authState, setAuthState] = useState('unauthenticated'); + const [showLoginSheet, setShowLoginSheet] = useState(false); + const [currentPage, setCurrentPage] = useState('main'); + const [emailInput, setEmailInput] = useState(''); + const [isEmailValid, setIsEmailValid] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const validateEmail = useCallback((email: string) => { + const regex = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; + setIsEmailValid(regex.test(email)); + }, []); + + const showLogin = useCallback(() => { + setShowLoginSheet(true); + setCurrentPage('main'); + }, []); + + const dismissLogin = useCallback(() => { + setShowLoginSheet(false); + setCurrentPage('main'); + setEmailInput(''); + setIsEmailValid(false); + }, []); + + const navigateToOtherSocials = useCallback(() => setCurrentPage('otherSocials'), []); + const navigateToWalletSelection = useCallback(() => setCurrentPage('walletSelection'), []); + const navigateBack = useCallback(() => setCurrentPage('main'), []); + + const mockLogin = useCallback(() => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + setShowLoginSheet(false); + setAuthState('authenticated'); + }, 800); + }, []); + + const logout = useCallback(() => { + setAuthState('unauthenticated'); + setEmailInput(''); + setIsEmailValid(false); + }, []); + + return { + authState, showLoginSheet, currentPage, emailInput, isEmailValid, isLoading, + setEmailInput, validateEmail, showLogin, dismissLogin, + navigateToOtherSocials, navigateToWalletSelection, navigateBack, + mockLogin, logout, + }; +} diff --git a/pocket-pwa/src/hooks/useChat.ts b/pocket-pwa/src/hooks/useChat.ts new file mode 100644 index 0000000..c2fe728 --- /dev/null +++ b/pocket-pwa/src/hooks/useChat.ts @@ -0,0 +1,40 @@ +import { useState, useCallback } from 'react'; +import type { ChatMessage, Agent } from '../types'; +import { AGENTS, SAMPLE_MESSAGES } from '../data/sampleData'; + +export function useChat() { + const [messages, setMessages] = useState(SAMPLE_MESSAGES); + const [inputText, setInputText] = useState(''); + const [selectedAgent, setSelectedAgent] = useState(AGENTS[0]); + const [isSending, setIsSending] = useState(false); + + const sendMessage = useCallback(() => { + const trimmed = inputText.trim(); + if (!trimmed || isSending) return; + + const userMsg: ChatMessage = { + id: Date.now().toString(), + sender: { type: 'user' }, + text: trimmed, + time: new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), + }; + setMessages(prev => [...prev, userMsg]); + setInputText(''); + setIsSending(true); + + setTimeout(() => { + const agentMsg: ChatMessage = { + id: (Date.now() + 1).toString(), + sender: { type: 'agent', name: selectedAgent.name }, + text: `I'm analyzing your request about "${trimmed}". Let me check the latest data...`, + time: new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }), + }; + setMessages(prev => [...prev, agentMsg]); + setIsSending(false); + }, 1500); + }, [inputText, isSending, selectedAgent]); + + const selectAgent = useCallback((agent: Agent) => setSelectedAgent(agent), []); + + return { messages, inputText, setInputText, selectedAgent, isSending, sendMessage, selectAgent }; +} diff --git a/pocket-pwa/src/hooks/useMarket.ts b/pocket-pwa/src/hooks/useMarket.ts new file mode 100644 index 0000000..ce9d922 --- /dev/null +++ b/pocket-pwa/src/hooks/useMarket.ts @@ -0,0 +1,25 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { MarketItem, MarketTimeframe } from '../types'; +import { SAMPLE_MARKET_ITEMS } from '../data/sampleData'; + +export function useMarket() { + const [marketItems] = useState(SAMPLE_MARKET_ITEMS); + const [searchText, setSearchText] = useState(''); + const [selectedTimeframe, setSelectedTimeframe] = useState('1D'); + const [isLoading, setIsLoading] = useState(false); + + const filteredItems = useMemo(() => { + if (!searchText.trim()) return marketItems; + const lower = searchText.toLowerCase(); + return marketItems.filter(i => i.name.toLowerCase().includes(lower) || i.symbol.toLowerCase().includes(lower)); + }, [marketItems, searchText]); + + const refreshMarket = useCallback(() => { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 1500); + }, []); + + const selectTimeframe = useCallback((tf: MarketTimeframe) => setSelectedTimeframe(tf), []); + + return { marketItems, filteredItems, searchText, setSearchText, selectedTimeframe, isLoading, refreshMarket, selectTimeframe }; +} diff --git a/pocket-pwa/src/hooks/usePortfolio.ts b/pocket-pwa/src/hooks/usePortfolio.ts new file mode 100644 index 0000000..0d3859c --- /dev/null +++ b/pocket-pwa/src/hooks/usePortfolio.ts @@ -0,0 +1,26 @@ +import { useState, useCallback } from 'react'; +import type { Asset, AgentDetail } from '../types'; +import { SAMPLE_PORTFOLIO, SAMPLE_ASSETS, SAMPLE_AGENT_DETAILS } from '../data/sampleData'; + +export function usePortfolio() { + const [portfolio] = useState(SAMPLE_PORTFOLIO); + const [assets] = useState(SAMPLE_ASSETS); + const [agentDetails, setAgentDetails] = useState(SAMPLE_AGENT_DETAILS); + const [isRefreshing, setIsRefreshing] = useState(false); + + const totalBalance = assets.reduce((sum, a) => sum + a.value, 0); + const activeAgentsCount = agentDetails.filter(d => d.status === 'Running').length; + + const refreshPortfolio = useCallback(() => { + setIsRefreshing(true); + setTimeout(() => setIsRefreshing(false), 1500); + }, []); + + const toggleAgent = useCallback((id: string) => { + setAgentDetails(prev => prev.map(d => + d.id === id ? { ...d, status: d.status === 'Running' ? 'Paused' as const : 'Running' as const } : d + )); + }, []); + + return { portfolio, assets, agentDetails, isRefreshing, totalBalance, activeAgentsCount, refreshPortfolio, toggleAgent }; +} diff --git a/pocket-pwa/src/index.css b/pocket-pwa/src/index.css new file mode 100644 index 0000000..aac4d1d --- /dev/null +++ b/pocket-pwa/src/index.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +@layer base { + :root { + --radius: 0.5rem + } +} + diff --git a/pocket-pwa/src/lib/utils.ts b/pocket-pwa/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/pocket-pwa/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/pocket-pwa/src/main.tsx b/pocket-pwa/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/pocket-pwa/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/pocket-pwa/src/types/index.ts b/pocket-pwa/src/types/index.ts new file mode 100644 index 0000000..294d4d2 --- /dev/null +++ b/pocket-pwa/src/types/index.ts @@ -0,0 +1,113 @@ +// Agent Model +export interface Agent { + id: string; + name: string; + color: string; + icon: string; +} + +// Chat Message +export interface ChatMessage { + id: string; + sender: MessageSender; + text: string; + time: string; + tradeBadge?: TradeBadge; +} + +export type MessageSender = { type: 'agent'; name: string } | { type: 'user' }; + +// Trade Badge +export interface TradeBadge { + id: string; + action: 'Bought' | 'Sold'; + amount: number; + ticker: string; +} + +// Stock Data +export interface StockData { + id: string; + ticker: string; + price: number; + chartPoints: number[]; + color: string; +} + +// Portfolio Data +export interface PortfolioData { + balance: number; + changePercent: number; + isDown: boolean; +} + +// Tab Definition +export type AppTab = 'home' | 'chat' | 'market' | 'settings'; + +export const APP_TABS: { key: AppTab; title: string; icon: string }[] = [ + { key: 'home', title: 'Home', icon: 'Home' }, + { key: 'chat', title: 'Chat', icon: 'MessageSquare' }, + { key: 'market', title: 'Market', icon: 'TrendingUp' }, + { key: 'settings', title: 'Settings', icon: 'Settings' }, +]; + +// Asset Model +export interface Asset { + id: string; + name: string; + symbol: string; + balance: number; + value: number; + changePercent: number; + isUp: boolean; + chainType: 'ethereum' | 'solana'; +} + +// Wallet Info +export interface WalletInfo { + id: string; + address: string; + chainType: 'Ethereum' | 'Solana'; + isEmbedded: boolean; +} + +// Agent Status +export type AgentStatus = 'Running' | 'Paused' | 'Stopped'; + +export interface AgentDetail { + id: string; + agent: Agent; + status: AgentStatus; + description: string; + profitLoss: number; +} + +// Market Item +export interface MarketItem { + id: string; + name: string; + symbol: string; + price: number; + changePercent: number; + isUp: boolean; + chartPoints: number[]; +} + +// Wallet Option +export interface WalletOption { + id: string; + name: string; + chains: ('ethereum' | 'solana' | 'multichain')[]; +} + +// Login Page +export type LoginPage = 'main' | 'otherSocials' | 'walletSelection'; + +// Auth State +export type AuthState = 'unauthenticated' | 'authenticating' | 'authenticated'; + +// Market Timeframe +export type MarketTimeframe = '1H' | '1D' | '1W' | '1M' | '1Y'; + +// Carousel Card Type +export type CarouselCardType = 'portfolio' | 'chat' | 'stockChart'; diff --git a/pocket-pwa/src/vite-env.d.ts b/pocket-pwa/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/pocket-pwa/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/pocket-pwa/tailwind.config.js b/pocket-pwa/tailwind.config.js new file mode 100644 index 0000000..f1038ab --- /dev/null +++ b/pocket-pwa/tailwind.config.js @@ -0,0 +1,17 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], + theme: { + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + colors: {} + } + }, + plugins: [import("tailwindcss-animate")], +} + diff --git a/pocket-pwa/tsconfig.app.json b/pocket-pwa/tsconfig.app.json new file mode 100644 index 0000000..92418e2 --- /dev/null +++ b/pocket-pwa/tsconfig.app.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Tailwind stuff */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} + diff --git a/pocket-pwa/tsconfig.json b/pocket-pwa/tsconfig.json new file mode 100644 index 0000000..0302a04 --- /dev/null +++ b/pocket-pwa/tsconfig.json @@ -0,0 +1,18 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} + diff --git a/pocket-pwa/tsconfig.node.json b/pocket-pwa/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/pocket-pwa/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/pocket-pwa/vite.config.ts b/pocket-pwa/vite.config.ts new file mode 100644 index 0000000..d1b8436 --- /dev/null +++ b/pocket-pwa/vite.config.ts @@ -0,0 +1,13 @@ +import path from "path" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}) + diff --git a/screenshots/01_portfolio_card.png b/screenshots/01_portfolio_card.png new file mode 100644 index 0000000..5f1647d Binary files /dev/null and b/screenshots/01_portfolio_card.png differ diff --git a/screenshots/02_chat_card.png b/screenshots/02_chat_card.png new file mode 100644 index 0000000..86ca693 Binary files /dev/null and b/screenshots/02_chat_card.png differ diff --git a/screenshots/03_stock_chart_card.png b/screenshots/03_stock_chart_card.png new file mode 100644 index 0000000..885dde3 Binary files /dev/null and b/screenshots/03_stock_chart_card.png differ