Compare commits

...

48 Commits

Author SHA1 Message Date
89822dedb8 db rearch 2026-05-25 20:50:45 -04:00
3ccaeaa2e3 feat(android): add API client, tRPC bridge, and offline support
- Add Retrofit with kotlinx-serialization converter for tRPC endpoints
- Create TRPCApiService with type-safe wrappers for all procedures
- Implement AuthInterceptor for JWT injection from EncryptedSharedPreferences
- Add ErrorHandler with exponential backoff retry logic and ApiResult sealed class
- Create 11 serializable data models matching backend enums
- Add JSON file-based cache with TTL invalidation (CacheManager)
- Implement repositories: User, DarkWatch, VoicePrint, Alert, Subscription
- Add offline sync: PendingRequestQueue, OfflineWorker, SyncManager
- Create manual DI modules: NetworkModule, DatabaseModule, RepositoryModule
- Add WorkManager for background offline request processing
- Add ConnectivityManager-based network monitoring for auto-sync
- Configure build system with KSP for Room, kotlinx-serialization plugin
- Update build config with environment-specific API URLs
- Write 19 new unit tests for ErrorHandler, CacheManager, TRPCResponse, SyncManager
2026-05-25 20:41:53 -04:00
a90534e164 feat(android): implement auth screens, onboarding flow, and account setup
- Add AuthRepository with EncryptedSharedPreferences and OkHttp API calls
- Add AuthViewModel with login/signup/reset/Google Sign-In flows
- Create auth screens: AuthScreen, LoginScreen, SignupScreen,
  ForgotPasswordScreen, ResetPasswordScreen, BiometricAuthScreen
- Create onboarding screens with HorizontalPager: PlanSelection,
  WatchlistSetup, FamilyInvite, Complete (with checkmark animation)
- Wire auth state to navigation: unauthenticated→auth, new→onboarding,
  authenticated→dashboard
- Add PasswordStrength utility with tests
- Add dependencies: security-crypto, biometric, play-services-auth,
  okhttp, gson, lottie-compose, material-icons-core
- Add unit tests: 23 tests passing for AuthViewModel and PasswordStrength
2026-05-25 20:24:33 -04:00
325be03797 feat(android): add design system components matching web theme
Implement 10 reusable Jetpack Compose UI components:
- ShieldButton: 4 variants (primary/secondary/ghost/danger), 3 sizes, loading state, icon support
- ShieldCard: gradient background matching web .gradient-card, click handling, header/footer slots
- ShieldTextField: validation, password toggle, error/helper text, focus styling
- ShieldBadge: 5 variants (default/success/warning/error/info), pill shape, icon support
- ShieldModal: ModalBottomSheet + AlertDialog, swipe-to-dismiss
- ShieldToast: Snackbar-based with 4 variants, auto-dismiss, action buttons
- ShieldAvatar: Coil async image loading, initials fallback, online status indicator
- ShieldProgressBar: linear progress with percentage, 5 color variants
- ShieldEmptyState: icon, title, description, action button
- ShieldSkeleton: shimmer animation with infinite transition

All components use theme tokens (no hardcoded colors) and support light/dark modes.

Add Coil dependency for avatar image loading.
Add ComponentShowcase preview with light/dark mode support.
Add instrumented Compose UI tests for all components.
2026-05-25 20:15:27 -04:00
35bc5f4af1 feat(android): establish Jetpack Compose foundation with navigation and shared theme
- Set up project namespace to com.shieldai.android
- Add navigation-compose dependency
- Create ShieldAI brand color palette (Color.kt) with light/dark tokens
- Add Material3 Typography scale (Type.kt) with all text styles
- Create Shape.kt with small/medium/large rounded corners
- Implement ShieldAITheme with light/dark color schemes and optional dynamic color
- Define Screen sealed class with all destinations (Dashboard, Services, Alerts, Settings, Account, Login, Signup, Onboarding, ServiceDetail)
- Create NavGraph with composable routes and placeholder screens
- Implement BottomNavBar with 5 navigation items with icons
- Create AppNavigation with Scaffold, NavHost, and bottom nav visibility logic
- Add ShieldAIApp Application class and updated MainActivity
- Create vector drawables for bottom nav icons
- Add test files under new package
- Remove old template package (com.mikefreno.shieldai)
2026-05-25 20:08:42 -04:00
78c63f018c iOS: update submodule reference for native features (push, biometrics, voice, camera, document scanner) 2026-05-25 20:03:00 -04:00
8cf26e04af feat(iOS): implement dashboard, service screens, and settings views
- Dashboard with threat score gauge, recent alerts, service cards, quick actions
- AlertDetail with severity header, correlated alerts, resolve/false-positive actions
- DarkWatch with watchlist CRUD, exposures list, scan button
- VoicePrint with enrollments, analysis history, recording sheet
- SpamShield with stats, number checking, custom rules CRUD
- HomeTitle with property watchlist and property detail view
- RemoveBrokers with broker registry and removal requests with progress
- Settings with account, subscription, preferences, family, logout
- CRUD mutations added to TRPCalling protocol
- SpamCheckResult model added
- Comprehensive unit tests for all view models
2026-05-25 19:45:19 -04:00
7625d0caea feat(ios): add API client, tRPC bridge, offline support, and model definitions
- APIClient: URLSession wrapper with auth, retry, logging
- TRPCBridge: tRPC procedure caller with type-safe wrappers
- Models: 14 Codable structs matching backend schema
- CacheManager: TTL-based offline caching
- OfflineQueue: persistent mutation queue with retry
- NetworkMonitor: connectivity tracking via NWPathMonitor
- Tests: unit tests for all components (92 total, all passing)
2026-05-25 19:23:31 -04:00
0fc7b2e745 chore: update iOS submodule with design system components 2026-05-25 18:38:59 -04:00
a5aeace438 task 28: update iOS sub-repo reference with app foundation changes 2026-05-25 18:29:04 -04:00
b03096f19d feat(browser-ext): move browser extension to browser-ext/ and update API client to tRPC
- Create browser-ext/ with full extension code (MV3 manifest, background
  service worker, content script, popup, options page)
- Add tRPC API client that communicates with unified monolith endpoints
- Implement cache, settings, and phishing detection utilities
- Create extension tRPC router in web app (getAuthStatus, linkDevice,
  reportPhishing)
- Configure Vite build with manifest V3 support
- Write unit tests for cache, phishing detector, and API client
- All 20 tests passing, TypeScript lint clean
2026-05-25 18:13:44 -04:00
20dc5bf785 feat: add error boundaries, loading skeletons, page transitions, and empty states
- ErrorBoundary: global error boundary with ShieldAI branding, retry/report
- Skeleton: SkeletonText, SkeletonCard, SkeletonAvatar, SkeletonTable
- PageTransition: fade-in + translate-y on route change, respects reduced motion
- EmptyState: reusable component with icon, title, description, action
- Button: add ariaLabel prop support
- Toast: add aria-live=polite region
- Dashboard: replace pulse divs with SkeletonCard fallbacks
- Service pages: add skeleton layouts, empty states, mutation loading states
- 404 page: polished with ShieldAI branding and home navigation
- app.tsx: add ErrorBoundary, PageTransition, skip-to-content link
- app.css: add page-enter animation with prefers-reduced-motion support
2026-05-25 18:05:29 -04:00
c02457c66a feat: real-time alerts via WebSocket push notifications
- Add ws WebSocket server (port 3001) with JWT auth and user-socket mapping
- Add WebSocket client with exponential backoff reconnection and heartbeat
- Add useRealtimeAlerts hook with toast notifications and unread badge
- Add alert.publisher service (WS → push → email fallback)
- Integrate publisher into DarkWatch, VoicePrint, HomeTitle, SpamShield, RemoveBrokers
- Update Navbar with connection status indicator and unread count
- Add comprehensive tests (14 passing) for server, client, and publisher
2026-05-25 17:58:47 -04:00
3a8e329f02 feat: dashboard unified widgets for all services
Implement 8 rich dashboard widgets replacing placeholder stat cards:
- ThreatScoreWidget: SVG circular gauge (0-100) with color-coded score
- AlertFeedWidget: Real-time alert stream with mark-as-read actions
- ExposureWidget: DarkWatch exposure summary with run-scan button
- VoicePrintWidget: Enrollment/analysis counts with mini bar chart
- SpamShieldWidget: Blocked calls/SMS stats with custom rules
- HomeTitleWidget: Watched properties and recent changes
- RemoveBrokersWidget: Broker registry progress with completion bar
- QuickActionsWidget: Shortcut buttons for common tasks

Update dashboard route with responsive 2-column grid layout,
auto-refresh via 60-second intervals, Suspense boundaries,
and skeleton loading states. Update tests for new widget layout.
2026-05-25 17:45:40 -04:00
7cbcde6a6b feat: wire frontend pages to tRPC APIs
- Add hooks (useAuth, useSubscription, useNotifications) for real API data
- Add auth service (login/signup) with password hashing and session support
- Replace stub auth with real tRPC calls in login/signup/onboarding pages
- Replace mock dashboard data with real API data from hooks
- Create service pages: DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers, Settings
- Update Navbar, TopBar, Sidebar with real user data and correct routes
- Add passwordHash field to users schema for credential auth
- Fix tests to work with real hooks (mock tRPC/hooks)
2026-05-25 17:34:48 -04:00
eb8e57c674 feat: implement background job system with queue, worker, scheduler, and handlers
- Add job queue abstraction (InMemoryQueue and Redis/BullMQ adapter)
- Add polling worker with retry logic and exponential backoff
- Add 6 job handlers: darkwatch.scan, voiceprint.batch, hometitle.scan,
  removebrokers.process, reports.generate, notifications.send
- Add cron-based scheduler with tier-appropriate frequencies
  (Basic/Plus/Premium)
- Add tRPC scheduler router for admin (runJobNow, getJobStatus, etc.)
- Add entry point with graceful shutdown support
- Achieve 100% test pass rate for new job system
2026-05-25 17:16:21 -04:00
659ab9b71a feat: implement security report generation backend (task 21)
- Add report-schedules DB schema table
- Create reports tRPC router with getReports, generateReport, getReport,
  deleteReport, getScheduledReports, updateSchedule procedures
- Create reports service with async report generation lifecycle
- Create report generator (compileData, renderHTML, generatePDF, uploadPDF)
- Add HTML templates for monthly-plus, annual-premium, weekly-digest
- Add Valibot schemas for input validation
- Wire router into root.ts and update DB schema exports/relations
- Install puppeteer for HTML-to-PDF conversion
- Write unit tests for router (11 tests) and service (12 tests)
2026-05-25 17:08:43 -04:00
4f7882a10d feat: add alert correlation & normalization engine with tRPC router
Implement the cross-service alert correlation and normalization engine:

- correlation router with 6 procedures: getAlerts, getAlertDetails,
  getGroups, getGroupDetails, resolveAlert, getStats
- correlation service with normalizeAlert, correlateAlerts,
  getAlertTimeline, resolveAlert, getThreatScore, getAlertStats
- correlation engine with findRelatedAlerts, createCorrelationGroup,
  updateGroupSeverity, deduplicateAlerts
- alert normalizer with service-specific converters for DarkWatch,
  SpamShield, VoicePrint, HomeTitle, and RemoveBrokers
- Entity extraction (emails, phones, SSNs) and threat scoring
  with severity-weighted decay over 30-day window
- 52 unit tests across engine, service, normalizer, and router
2026-05-25 16:55:31 -04:00
d84595bf72 feat: add RemoveBrokers tRPC router, service layer, broker registry, and removal engine
- Create Valibot schemas for removal request CRUD, scan, and listing filters
- Implement broker registry with 48 major data brokers and removal metadata
- Build removal engine with automated, form, email, and status tracking support
- Add service layer with subscription-scoped operations: create/get/scan/stats/process
- Wire removebrokers router into root app router
- Write 20 passing unit tests (router + service layer)
2026-05-25 16:47:31 -04:00
a3fee924d8 feat(hometitle): add Backend Router — HomeTitle (Property Monitoring)
- Add hometitle schema (Valibot input schemas)
- Add change detector (fuzzy matching, severity, change detection)
- Add scanner module (geocoding, county records placeholder)
- Add hometitle service (property CRUD, scan, alert pipeline)
- Add hometitle router (7 tRPC procedures)
- Wire into api root
- Add alert type 'property_change' to enum
- Write unit tests (10 tests, all passing)
2026-05-25 16:38:34 -04:00
fc9a5c4fb2 feat: add SpamShield router for spam detection and call analysis
- Create tRPC router with checkNumber, classifySMS, classifyCall endpoints
- Add protected procedures for rule CRUD, feedback submission, and stats
- Implement service layer with phone number normalization and audit logging
- Add ML engine with BERT stub, feature extraction, and rule engine
- Implement reputation API module with circuit breaker and caching
- Write comprehensive tests (34 tests) for all layers
- Wire spamshield router into app root router
2026-05-25 16:34:08 -04:00
e6b07ddf1d cleanup 2026-05-25 16:31:39 -04:00
bec8cbf269 feat: add VoicePrint tRPC router with service, ML engine, and storage modules
- Create voiceprint router with 7 protected procedures:
  getEnrollments, createEnrollment, deleteEnrollment, analyzeAudio,
  getAnalyses, getAnalysisResult, getJobStatus
- Create voiceprint service with core business logic:
  enrollment CRUD, audio analysis pipeline, alert creation, batch jobs
- Create ML engine with placeholder implementations for
  audio preprocessing, synthetic detection, voice matching, embedding
- Create storage module for audio file persistence on disk
- Add valibot schemas for input validation
- Wire voiceprint router into root tRPC router
- Add comprehensive unit tests (33 tests, all passing)
2026-05-25 16:28:43 -04:00
b2c3470a71 feat(darkwatch): implement DarkWatch tRPC router and service layer
- Add darkwatch router with procedures: getWatchlist, addWatchlistItem,
  removeWatchlistItem, getExposures, getExposureDetails, runScan,
  getScanStatus, getReports
- Add darkwatch service with watchlist CRUD, exposure queries,
  scan orchestration, tier limit enforcement, report listing
- Add scan engine with HIBP, SecurityTrails, Censys, Shodan, and
  forum scraping modules (circuit breaker pattern, env-var API keys)
- Add alert pipeline with severity scoring, deduplication, and
  exposure-to-alert creation
- Add valibot schemas for input validation
- Register router in root.ts
- Write unit tests for router procedures, service functions,
  and severity scoring (21 tests passing)
2026-05-25 16:19:23 -04:00
5154990acd feat(notifications): implement notification router with email, push, SMS support
- Add notification router (sendEmail, sendPush, sendSMS, device mgmt, prefs)
- Create provider clients: Resend, Firebase Admin (FCM), Twilio
- Add notification_preferences table to Drizzle schema
- Create branded email templates (welcome, alert, password reset, family invite, billing)
- Implement notification service with error handling and E.164 validation
- Wire router into app root
- Write unit tests with mocked providers (25 tests passing)
- Add resend, firebase-admin, twilio dependencies
2026-05-25 16:13:02 -04:00
40a9ef146c feat(billing): add subscription and Stripe billing router
- Add stripeCustomerId column to users table
- Create Stripe client initialization (web/src/server/stripe.ts)
- Add billing service with getOrCreateCustomer, checkout/portal sessions,
  subscription management, invoice listing, and webhook event handling
- Create billing tRPC router with getSubscription, createCheckoutSession,
  createPortalSession, cancelSubscription, reactivateSubscription, listInvoices
- Add raw webhook endpoint at /api/stripe/webhook with signature verification
- Define Valibot schemas for all billing procedure inputs
- Wire billing router into root tRPC router
- Update schema tests for new column/index counts
- Write unit tests for billing service and router
2026-05-25 16:07:00 -04:00
28c33a930d feat: implement user & family group management tRPC router
- Add user router with me/update/delete procedures (protected)
- Add family router with listMembers/invite/remove/updateRole procedures
- Create user service layer (getUserById, updateUser, deleteUser)
- Create family service layer (getFamilyGroup, inviteMember, removeMember, updateMemberRole, transferOwnership)
- Add Valibot input schemas for all procedures
- Add invitations table with status tracking and expiration
- Add deletedAt column to users table (soft-delete)
- Wire user router into app root router
- Write unit tests for service functions and tRPC procedures
- Update schema tests for new table/columns
2026-05-25 15:57:33 -04:00
71972436b6 feat: add tRPC auth context, middleware, and protected procedures
- Install jose (JWT) and bcryptjs (password hashing) dependencies
- Create auth utilities: JWT sign/verify, password hash/verify, session management
- Create createTRPCContext that extracts auth from session cookie, Bearer JWT, or x-api-key
- Add publicProcedure, protectedProcedure, adminProcedure, rateLimitedProcedure with middleware
- Wire context builder into SolidStart tRPC API handler
- Update tRPC client to inject auth tokens and handle 401 redirects
- Add unit tests for JWT, password, context builder, and middleware
2026-05-25 15:46:52 -04:00
052e08c17b feat(db): add PostgreSQL connection, migration runner, and seed data
- Add pool export and graceful shutdown hook to db/index.ts
- Create migrate.ts — programmatic migration runner using drizzle-orm/migrator
- Create seed.ts — idempotent seed script with sample users, subscriptions,
  watchlist items, exposures, alerts, blog posts, properties, and removal requests
- Create db.test.ts — unit tests for db, migrate, and seed module exports
- Add web/.env.example documenting DATABASE_URL
- Add db:generate, db:push, db:migrate, db:seed scripts to web/package.json
2026-05-25 15:39:20 -04:00
bc20aeaeb6 feat: migrate full Prisma schema to Drizzle ORM (29 tables, 28 enums, 25 relations)
- Install drizzle-orm, drizzle-kit, pg, @types/pg in web/
- Create split schema directory with domain files:
  - auth (users, accounts, sessions, deviceTokens)
  - subscription (familyGroups, familyGroupMembers, subscriptions)
  - darkwatch (watchlistItems, exposures)
  - alerts
  - voiceprint (voiceEnrollments, voiceAnalyses, analysisJobs, analysisResults)
  - spamshield (spamFeedback, spamRules)
  - audit (auditLogs, kpiSnapshots)
  - correlation (normalizedAlerts, correlationGroups)
  - reports (securityReports)
  - marketing (waitlistEntries, blogPosts)
  - hometitle (propertyWatchlistItems, propertySnapshots, propertyChanges)
  - removebrokers (infoBrokers, removalRequests, brokerListings)
- Define all 28 PostgreSQL enums via pgEnum()
- Define all indexes, unique constraints, and foreign keys
- Define all 25 relation definitions via relations() helper
- Update drizzle.config.ts for PostgreSQL dialect
- Update db/index.ts for node-postgres connection
- Replace old placeholder schema.ts with barrel re-export
- Add 38 comprehensive schema tests
2026-05-25 15:35:10 -04:00
9dc55517b1 08: Migrate & redesign Blog, Ads, and Dashboard pages
- Blog listing page with hero, responsive grid, tag filters, load more
- Blog post page with markdown rendering, related posts, social share
- Ads landing page with conversion copy, pricing, FAQ, testimonials
- Dashboard shell with sidebar, topbar, stat cards, activity feed
- Dashboard components: Sidebar, TopBar, StatCard, ActivityFeed, QuickActions
- Comprehensive test suite covering all pages and components
2026-05-25 15:26:01 -04:00
25da0cd687 feat: add auth pages (login, signup, password reset, onboarding)
- Create stub auth API (lib/auth.ts) with simulated delay
- Add PasswordInput component with visibility toggle
- Add SocialAuthButtons component (Google/Apple placeholders)
- Add AuthLayout with split-panel layout and rotating testimonial
- Implement login page with email/password validation and remember me
- Implement signup page with password strength indicator and ToS checkbox
- Implement forgot-password page with email submission and success state
- Implement reset-password page with token validation from query params
- Implement 4-step onboarding flow (plan selection, watchlist, invites, success)
- Add ToastProvider to root app
- Write 28 tests for all auth components and form validation
2026-05-25 15:20:01 -04:00
6acbb6ca37 feat: Add Typewriter animation to hero headline
- Wrap hero headline in Typewriter with 50cps speed, 400ms delay
- Preserves gradient text effect on 'Identity Protection' span
- Update README task 03 to list Typewriter as a UI primitive
2026-05-25 15:14:30 -04:00
3f00dd6b28 feat: add landing page Features, How It Works, and CTA sections
- HowItWorksSection: 3-step staggered timeline with gradient circles
- FeaturesGridSection: 6-card responsive grid (DarkWatch, VoicePrint, SpamShield, HomeTitle, RemoveBrokers, Family Plans)
- ForUsersSection: Split panel for Individuals and Families with checkmark lists
- WhyShieldAISection: 3 value prop cards (Proactive, AI-Powered, Privacy First)
- CTABannerSection: Final CTA with Create Account and Sign In buttons
- Updated routes/index.tsx with clip-path polygon transitions between sections
- Added 49 unit tests for all new sections
2026-05-25 15:12:32 -04:00
d4c1b62a97 refactor: Context-based theme provider with animated Typewriter toggle
- Convert theme to SolidJS Context/Provider pattern (ThemeProvider)
- Extract createThemeState() for testability without context
- Add Typewriter component with character-by-character reveal
- Animate ThemeToggle with Typewriter label and hover scale
- Add cursor CSS animations (typewriter-blink, cursor-typing, cursor-block)
- Fix background color transition by using 'all' on :root
- Rename theme.ts -> theme.tsx for JSX support
- All 26 theme tests passing
2026-05-25 15:10:52 -04:00
c9a82fc6de feat: add landing page hero section with animated background
- Create ColorWaveBackground canvas component with animated gradient blobs
- Create HeroSection with ShieldAI branding, headline, CTAs, and trust signals
- Compose landing page in index.tsx route with background + hero
- Add unit tests for HeroSection (13 tests) and ColorWaveBackground (8 tests)
- Background respects prefers-reduced-motion accessibility preference
- Responsive layout with mobile-first text sizing and stacked buttons
2026-05-25 14:43:09 -04:00
6981a05de4 feat: add layout components (Navbar, Footer, PageContainer, AppShell)
- Navbar: responsive nav with ShieldAI logo, nav links, auth buttons,
  mobile hamburger menu, theme toggle, scroll-aware glass effect
- Footer: multi-column responsive layout with product/company/resources/
  legal links, social icons, copyright bar
- PageContainer: centered wrapper with max-w-7xl and responsive padding
- AppShell: root layout composing Navbar + main + Footer with dot-grid
  background and MetaProvider
- useAuth stub hook for future auth integration (task 23)
- Wire AppShell into app.tsx as Router root
- Unit tests for PageContainer and useAuth
2026-05-25 13:58:42 -04:00
6002ea383b chore: mark tasks 01-03 as complete in README 2026-05-25 13:31:32 -04:00
3842a20b35 feat(theme): add @theme block inside dark media query for Tailwind v4 compile-time dark tokens 2026-05-25 13:22:30 -04:00
cc41f4ad32 feat: establish root config and workspace foundation
- Create browser-ext placeholder package.json for workspace resolution
- Update root engines to node >=22 matching .nvmrc and web/package.json
- pnpm-workspace.yaml already configured with web and browser-ext
- All legacy directories (packages/, services/, server/) already removed
2026-05-25 13:17:55 -04:00
ee31b88612 feat: add full @property declarations and fix theme system
- Add @property declarations for all 28 animatable color tokens ensuring
  smooth 500ms transitions between light/dark modes
- Remove invalid @theme block from inside @media (prefers-color-scheme: dark)
  that was causing Tailwind v4 to use dark values as defaults
- Add FOUC-prevention inline script in entry-server.tsx that applies
  theme class before first paint
- Integrate useTheme() hook in app.tsx for meta theme-color updates
  and system preference change listener
2026-05-25 13:14:30 -04:00
aa69c0ecc4 chore: project foundation cleanup — remove npm lockfile and turbo cache from tracking
- Remove package-lock.json (project uses pnpm)
- Remove .turbo/cache/ from git tracking (already in .gitignore)
- Add package-lock.json to .gitignore to prevent accidental re-addition
2026-05-25 13:08:08 -04:00
4118a25388 feat: add UI primitive library — Button, Card, Input, Badge, Modal, Toast
- Add cn() utility for class merging in lib/utils.ts
- Button: primary/secondary/ghost/danger variants, sm/md/lg sizes, disabled/loading states
- Card: gradient-card background with optional header/footer slots
- Input: text/email/password/number types with label, error, helper text, focus ring
- Badge: default/success/warning/error/info variants
- Modal: Portal-based dialog with focus trap, ESC/backdrop close, animations
- Toast: ToastProvider context with show/dismiss/auto-dismiss and variant support
- Barrel export via index.ts
- 46 unit tests across all primitives
- Configure vitest with vite-plugin-solid for JSX support
2026-05-25 13:03:00 -04:00
06bf9ac97c feat: add ShieldAI theme system with auto-shifting CSS and useTheme hook 2026-05-25 12:42:26 -04:00
f627033665 feat: establish unified project foundation with root config cleanup
- Archive legacy packages/, services/, server/ directories
- Update pnpm workspace to web + browser-ext
- Simplify root package.json scripts to delegate to web/
- Update turbo.json for new workspace structure
- Remove obsolete root config files (vite, tsconfig, etc.)
- Add .nvmrc, .editorconfig for consistent dev environment
- Update CI workflow to remove references to deleted packages
- Add missing dependencies (@tailwindcss/vite, tailwindcss) to web
- Add test and lint scripts to web package
- Verify pnpm install, build, and dev work correctly
2026-05-25 12:31:43 -04:00
59fcc31483 restructure tasks 2026-05-25 12:23:23 -04:00
24459442a2 android + ios base 2026-05-25 12:02:31 -04:00
4471719b79 basic redux setup 2026-05-25 11:41:43 -04:00
940 changed files with 47594 additions and 99722 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -89,8 +89,8 @@ jobs:
version: ${{ env.PNPM_VERSION }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests with coverage
run: pnpm test:coverage
- name: Run tests
run: pnpm test
env:
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
REDIS_URL: "redis://localhost:6379"
@@ -106,22 +106,6 @@ jobs:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
strategy:
fail-fast: false
matrix:
include:
- name: api
context: .
dockerfile: packages/api/Dockerfile
- name: darkwatch
context: .
dockerfile: services/darkwatch/Dockerfile
- name: spamshield
context: .
dockerfile: services/spamshield/Dockerfile
- name: voiceprint
context: .
dockerfile: services/voiceprint/Dockerfile
steps:
- uses: actions/checkout@v4
- name: Docker Buildx
@@ -129,10 +113,9 @@ jobs:
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
context: .
push: false
tags: shieldai-${{ matrix.name }}:${{ github.sha }}
tags: shieldai:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

4
.gitignore vendored
View File

@@ -1,7 +1,9 @@
node_modules
dist
.output
.env
*.log
.DS_Store
load-tests/voiceprint/results/
.turbo
.nitro
package-lock.json

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -1 +0,0 @@
{"files":{"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/index.js":{"size":3531,"mtime_nanos":1778380725084978870,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2294,"mtime_nanos":1778380725084978870,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1778380725078978662,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1778380725078978662,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1778380725074978523,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1778380725074978523,"mode":420,"is_dir":false},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1778380725118980048,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":7296,"mtime_nanos":1778380725099979390,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":9902,"mtime_nanos":1778380725099979390,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"47854326d2b77c8e","duration":744,"sha":"de0ddac65df311d7ef051c48ad6291d8de8618f3","dirty_hash":"a8bcf9ec37f7505b9b259118f068359e59ffb7bdae53135b3b2ec7ca027f5c2d"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777817946251116749,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1777817946232116132,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777817946270117366,"mode":420,"is_dir":false},"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777817946240116392,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1777817946235116229,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1777817946232116132,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1777817946235116229,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777817946240116392,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777817946251116749,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"6abb2efbabfd492c","duration":728,"sha":"a4684e912110fdf2702981e23494be96df91b86f","dirty_hash":"85a4cfa756e84c777eeff88ca5a3d970b636968eb72658995bfec15eeba2d9b4"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{"packages/correlation/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/correlation/dist/engine.js.map":{"size":9890,"mtime_nanos":1777721551087749490,"mode":420,"is_dir":false},"packages/correlation/dist/index.js":{"size":1909,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/.turbo/turbo-build.log":{"size":90,"mtime_nanos":1777721551125750542,"mode":420,"is_dir":false},"packages/correlation/dist/service.d.ts.map":{"size":2091,"mtime_nanos":1777721551100749850,"mode":420,"is_dir":false},"packages/correlation/dist/index.d.ts.map":{"size":346,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/index.js.map":{"size":388,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.js":{"size":6535,"mtime_nanos":1777721551064748853,"mode":420,"is_dir":false},"packages/correlation/dist/service.js":{"size":2496,"mtime_nanos":1777721551093749656,"mode":420,"is_dir":false},"packages/correlation/dist/index.d.ts":{"size":347,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/engine.js":{"size":10672,"mtime_nanos":1777721551087749490,"mode":420,"is_dir":false},"packages/correlation/dist/engine.d.ts.map":{"size":1146,"mtime_nanos":1777721551089749545,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.d.ts":{"size":1601,"mtime_nanos":1777721551071749047,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.d.ts.map":{"size":1561,"mtime_nanos":1777721551071749047,"mode":420,"is_dir":false},"packages/correlation/dist/service.d.ts":{"size":2700,"mtime_nanos":1777721551100749850,"mode":420,"is_dir":false},"packages/correlation/dist/engine.d.ts":{"size":1292,"mtime_nanos":1777721551089749545,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.js":{"size":2425,"mtime_nanos":1777721551105749988,"mode":420,"is_dir":false},"packages/correlation/dist/service.js.map":{"size":1947,"mtime_nanos":1777721551093749656,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.d.ts":{"size":946,"mtime_nanos":1777721551106750016,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.js.map":{"size":1719,"mtime_nanos":1777721551105749988,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.d.ts.map":{"size":1092,"mtime_nanos":1777721551106750016,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.js.map":{"size":5180,"mtime_nanos":1777721551063748825,"mode":420,"is_dir":false}},"order":["packages/correlation/.turbo/turbo-build.log","packages/correlation/dist","packages/correlation/dist/emitter.d.ts","packages/correlation/dist/emitter.d.ts.map","packages/correlation/dist/emitter.js","packages/correlation/dist/emitter.js.map","packages/correlation/dist/engine.d.ts","packages/correlation/dist/engine.d.ts.map","packages/correlation/dist/engine.js","packages/correlation/dist/engine.js.map","packages/correlation/dist/index.d.ts","packages/correlation/dist/index.d.ts.map","packages/correlation/dist/index.js","packages/correlation/dist/index.js.map","packages/correlation/dist/normalizer.d.ts","packages/correlation/dist/normalizer.d.ts.map","packages/correlation/dist/normalizer.js","packages/correlation/dist/normalizer.js.map","packages/correlation/dist/service.d.ts","packages/correlation/dist/service.d.ts.map","packages/correlation/dist/service.js","packages/correlation/dist/service.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"8ff5b7eb9e0aad01","duration":908,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{"packages/db/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/services/field-encryption.service.d.ts.map":{"size":330,"mtime_nanos":1777698592443009097,"mode":420,"is_dir":false},"packages/db/.turbo/turbo-build.log":{"size":511,"mtime_nanos":1777698592481009929,"mode":420,"is_dir":false},"packages/db/dist/index.js":{"size":535,"mtime_nanos":1777698592446009163,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.d.ts":{"size":252,"mtime_nanos":1777698592443009097,"mode":420,"is_dir":false},"packages/db/dist/index.js.map":{"size":217,"mtime_nanos":1777698592446009163,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js":{"size":1606,"mtime_nanos":1777698592439009009,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js.map":{"size":1414,"mtime_nanos":1777698592439009009,"mode":420,"is_dir":false},"packages/db/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/index.d.ts.map":{"size":308,"mtime_nanos":1777698592459009447,"mode":420,"is_dir":false},"packages/db/dist/index.d.ts":{"size":405,"mtime_nanos":1777698592459009447,"mode":420,"is_dir":false}},"order":["packages/db/.turbo/turbo-build.log","packages/db/dist","packages/db/dist/index.d.ts","packages/db/dist/index.d.ts.map","packages/db/dist/index.js","packages/db/dist/index.js.map","packages/db/dist/services","packages/db/dist/services/field-encryption.service.d.ts","packages/db/dist/services/field-encryption.service.d.ts.map","packages/db/dist/services/field-encryption.service.js","packages/db/dist/services/field-encryption.service.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"aacbad09f9d0c28b","duration":1972,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777698591363985482,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777698591336984892,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":519,"mtime_nanos":1777698591309984301,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":276,"mtime_nanos":1777698591309984301,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":1383,"mtime_nanos":1777698591304984191,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777698591336984892,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777698591318984498,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1299,"mtime_nanos":1777698591304984191,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777698591319984520,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"dbd09b3775d9469c","duration":855,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{"packages/db/dist/index.d.ts":{"size":405,"mtime_nanos":1777721550197724849,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.d.ts.map":{"size":330,"mtime_nanos":1777721550183724462,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js.map":{"size":1414,"mtime_nanos":1777721550180724379,"mode":420,"is_dir":false},"packages/db/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/index.d.ts.map":{"size":308,"mtime_nanos":1777721550197724849,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js":{"size":1606,"mtime_nanos":1777721550180724379,"mode":420,"is_dir":false},"packages/db/.turbo/turbo-build.log":{"size":1379,"mtime_nanos":1777721550215725348,"mode":420,"is_dir":false},"packages/db/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/services/field-encryption.service.d.ts":{"size":252,"mtime_nanos":1777721550183724462,"mode":420,"is_dir":false},"packages/db/dist/index.js":{"size":535,"mtime_nanos":1777721550186724545,"mode":420,"is_dir":false},"packages/db/dist/index.js.map":{"size":217,"mtime_nanos":1777721550186724545,"mode":420,"is_dir":false}},"order":["packages/db/.turbo/turbo-build.log","packages/db/dist","packages/db/dist/index.d.ts","packages/db/dist/index.d.ts.map","packages/db/dist/index.js","packages/db/dist/index.js.map","packages/db/dist/services","packages/db/dist/services/field-encryption.service.d.ts","packages/db/dist/services/field-encryption.service.d.ts.map","packages/db/dist/services/field-encryption.service.js","packages/db/dist/services/field-encryption.service.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"df12164dc3180a8f","duration":1557,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777754191886389843,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1777754191880389688,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777754191897390127,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777754191886389843,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777754191897390127,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1777754191880389688,"mode":420,"is_dir":false},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777754191919390695,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1777754191876389585,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1777754191876389585,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"df8d582601d96e8d","duration":684,"sha":"274afa63352200107e5e3ed5a783555fe3c68e37","dirty_hash":"1b22568f1b7a3df274940e36b290211b3251b700c1e1286bc843ed3e00b07e05"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{"packages/shared-billing/dist/models/subscription.model.js":{"size":1577,"mtime_nanos":1777698591971998787,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.js":{"size":3740,"mtime_nanos":1777698591945998218,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.d.ts":{"size":2511,"mtime_nanos":1777698592000999421,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.d.ts.map":{"size":1804,"mtime_nanos":1777698592000999421,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.js.map":{"size":6458,"mtime_nanos":1777698591993999268,"mode":420,"is_dir":false},"packages/shared-billing/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.d.ts":{"size":8876,"mtime_nanos":1777698591967998699,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.js":{"size":2386,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/config":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/index.js.map":{"size":352,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/models/subscription.model.d.ts":{"size":3467,"mtime_nanos":1777698591977998918,"mode":420,"is_dir":false},"packages/shared-billing/dist/models/subscription.model.js.map":{"size":1431,"mtime_nanos":1777698591971998787,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.d.ts.map":{"size":1125,"mtime_nanos":1777698592011999662,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.js":{"size":4164,"mtime_nanos":1777698592006999552,"mode":420,"is_dir":false},"packages/shared-billing/dist/models":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/models/subscription.model.d.ts.map":{"size":434,"mtime_nanos":1777698591976998896,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.js":{"size":7312,"mtime_nanos":1777698591993999268,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.d.ts":{"size":359,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/config/billing.config.d.ts.map":{"size":664,"mtime_nanos":1777698591967998699,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.d.ts":{"size":1176,"mtime_nanos":1777698592011999662,"mode":420,"is_dir":false},"packages/shared-billing/.turbo/turbo-build.log":{"size":96,"mtime_nanos":1777698592050000494,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.d.ts.map":{"size":317,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.js.map":{"size":3848,"mtime_nanos":1777698592006999552,"mode":420,"is_dir":false},"packages/shared-billing/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.js.map":{"size":3157,"mtime_nanos":1777698591945998218,"mode":420,"is_dir":false}},"order":["packages/shared-billing/.turbo/turbo-build.log","packages/shared-billing/dist","packages/shared-billing/dist/config","packages/shared-billing/dist/config/billing.config.d.ts","packages/shared-billing/dist/config/billing.config.d.ts.map","packages/shared-billing/dist/config/billing.config.js","packages/shared-billing/dist/config/billing.config.js.map","packages/shared-billing/dist/index.d.ts","packages/shared-billing/dist/index.d.ts.map","packages/shared-billing/dist/index.js","packages/shared-billing/dist/index.js.map","packages/shared-billing/dist/middleware","packages/shared-billing/dist/middleware/billing.middleware.d.ts","packages/shared-billing/dist/middleware/billing.middleware.d.ts.map","packages/shared-billing/dist/middleware/billing.middleware.js","packages/shared-billing/dist/middleware/billing.middleware.js.map","packages/shared-billing/dist/models","packages/shared-billing/dist/models/subscription.model.d.ts","packages/shared-billing/dist/models/subscription.model.d.ts.map","packages/shared-billing/dist/models/subscription.model.js","packages/shared-billing/dist/models/subscription.model.js.map","packages/shared-billing/dist/services","packages/shared-billing/dist/services/billing.service.d.ts","packages/shared-billing/dist/services/billing.service.d.ts.map","packages/shared-billing/dist/services/billing.service.js","packages/shared-billing/dist/services/billing.service.js.map"]}

View File

@@ -1 +0,0 @@
{"hash":"f810866ff5911e6a","duration":1541,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}

Binary file not shown.

View File

@@ -1,38 +0,0 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY apps/ ./apps/
COPY packages/ ./packages/
# Install dependencies
RUN npm ci
# Build all packages
RUN npm run build
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY apps/ ./apps/
COPY packages/ ./packages/
# Copy built artifacts from builder
COPY --from=builder /app/apps/web/dist ./apps/web/dist
COPY --from=builder /app/apps/api/dist ./apps/api/dist
# Install production dependencies only
RUN npm ci --production
# Expose port
EXPOSE 3000
# Start the API server
CMD ["node", "apps/api/dist/index.js"]

15
android/ShieldAI/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
android/ShieldAI/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
android/ShieldAI/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,92 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.shieldai.android"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "com.shieldai.android"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
buildConfigField("String", "API_STAGING_URL", "\"https://staging.api.shieldai.com\"")
buildConfigField("String", "API_PRODUCTION_URL", "\"https://api.shieldai.com\"")
}
buildTypes {
debug {
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "API_BASE_URL", "\"https://api.shieldai.com\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
lint {
baseline = file("lint-baseline.xml")
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation("androidx.compose.material:material-icons-core")
implementation(libs.coil.compose)
implementation(libs.lottie.compose)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.biometric)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.gson)
implementation(libs.play.services.auth)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.kotlinx.serialization.json)
implementation(libs.work.runtime.ktx)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.truth)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.work.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,521 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (9.1.1)" variant="all" version="9.1.1">
<issue
id="RedundantLabel"
message="Redundant label can be removed"
errorLine1=" android:label=&quot;@string/app_name&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="20"
column="13"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of Gradle than 9.3.1 is available: 9.5.1"
errorLine1="distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/wrapper/gradle-wrapper.properties"
line="5"
column="17"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of com.android.application than 9.1.1 is available: 9.2.1"
errorLine1="agp = &quot;9.1.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="2"
column="7"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.core:core-ktx than 1.10.1 is available: 1.18.0"
errorLine1="coreKtx = &quot;1.10.1&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="3"
column="11"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.test.ext:junit than 1.1.5 is available: 1.3.0"
errorLine1="junitVersion = &quot;1.1.5&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="5"
column="16"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.test.espresso:espresso-core than 3.5.1 is available: 3.7.0"
errorLine1="espressoCore = &quot;3.5.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="6"
column="16"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.6.1 is available: 2.10.0"
errorLine1="lifecycleRuntimeKtx = &quot;2.6.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="7"
column="23"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.activity:activity-compose than 1.8.0 is available: 1.13.0"
errorLine1="activityCompose = &quot;1.8.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="8"
column="19"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.7.7 is available: 2.9.8"
errorLine1="navigationCompose = &quot;2.7.7&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="9"
column="21"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose:compose-bom than 2025.12.00 is available: 2026.05.01"
errorLine1="composeBom = &quot;2025.12.00&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="11"
column="14"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.security:security-crypto than 1.1.0-alpha06 is available: 1.1.0"
errorLine1="securityCrypto = &quot;1.1.0-alpha06&quot;"
errorLine2=" ~~~~~~~~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="13"
column="18"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of com.google.android.gms:play-services-auth than 21.0.0 is available: 21.5.1"
errorLine1="playServicesAuth = &quot;21.0.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="15"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-runtime-ktx than 2.9.1 is available: 2.11.2"
errorLine1="work = &quot;2.9.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="23"
column="8"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-testing than 2.9.1 is available: 2.11.2"
errorLine1="work = &quot;2.9.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="23"
column="8"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.10 is available: 2.3.21"
errorLine1="kotlin = &quot;2.2.10&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="10"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.serialization than 2.2.10 is available: 2.3.21"
errorLine1="kotlin = &quot;2.2.10&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="10"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:logging-interceptor than 4.12.0 is available: 5.3.2"
errorLine1="okhttp = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="16"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:okhttp than 4.12.0 is available: 5.3.2"
errorLine1="okhttp = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="16"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.code.gson:gson than 2.10.1 is available: 2.14.0"
errorLine1="gson = &quot;2.10.1&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="17"
column="8"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.airbnb.android:lottie-compose than 6.4.0 is available: 6.7.1"
errorLine1="lottieCompose = &quot;6.4.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="18"
column="17"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlinx:kotlinx-coroutines-test than 1.7.3 is available: 1.11.0"
errorLine1="coroutinesTest = &quot;1.7.3&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="19"
column="18"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.retrofit2:retrofit than 2.11.0 is available: 3.0.0"
errorLine1="retrofit = &quot;2.11.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="20"
column="12"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlinx:kotlinx-serialization-json than 1.7.3 is available: 1.11.0"
errorLine1="kotlinxSerializationJson = &quot;1.7.3&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="22"
column="28"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.truth:truth than 1.4.4 is available: 1.4.5"
errorLine1="truth = &quot;1.4.4&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="24"
column="9"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:mockwebserver than 4.12.0 is available: 5.3.2"
errorLine1="mockwebserver = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="25"
column="17"/>
</issue>
<issue
id="LocalContextGetResourceValueCall"
message="Querying resource values using LocalContext.current"
errorLine1=" .requestIdToken(context.getString(com.shieldai.android.R.string.default_web_client_id))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/ui/screens/auth/LoginScreen.kt"
line="56"
column="29"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `UserRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var userRepository: UserRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="11"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `DarkWatchRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var darkWatchRepository: DarkWatchRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="12"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `VoicePrintRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var voicePrintRepository: VoicePrintRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="13"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `AlertRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var alertRepository: AlertRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="14"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `SubscriptionRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var subscriptionRepository: SubscriptionRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="15"
column="5"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_primary&quot;>#FF4F46E5&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="3"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_primary_light` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_primary_light&quot;>#FF818CF8&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="4"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_accent` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_accent&quot;>#FF06B6D4&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="5"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.bg_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;bg_primary&quot;>#FFFFFFFF&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="6"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.bg_primary_dark` appears to be unused"
errorLine1=" &lt;color name=&quot;bg_primary_dark&quot;>#FF0F172A&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="7"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.text_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;text_primary&quot;>#FF0F172A&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="8"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.text_primary_dark` appears to be unused"
errorLine1=" &lt;color name=&quot;text_primary_dark&quot;>#FFF1F5F9&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="9"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.success` appears to be unused"
errorLine1=" &lt;color name=&quot;success&quot;>#FF22C55E&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="10"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.warning` appears to be unused"
errorLine1=" &lt;color name=&quot;warning&quot;>#FFF59E0B&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="11"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.error` appears to be unused"
errorLine1=" &lt;color name=&quot;error&quot;>#FFEF4444&lt;/color>"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="12"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.info` appears to be unused"
errorLine1=" &lt;color name=&quot;info&quot;>#FF3B82F6&lt;/color>"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="13"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_home` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_home.xml"
line="1"
column="1"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" securePrefs.edit()"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/data/repository/AuthRepository.kt"
line="144"
column="9"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" securePrefs.edit()"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/data/repository/AuthRepository.kt"
line="155"
column="9"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" prefs.edit().putBoolean(&quot;biometric_enabled&quot;, enabled).apply()"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/ui/screens/auth/BiometricAuthScreen.kt"
line="88"
column="5"/>
</issue>
<issue
id="UseTomlInstead"
message="Use version catalog instead"
errorLine1=" implementation(&quot;androidx.compose.material:material-icons-core&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle.kts"
line="66"
column="20"/>
</issue>
</issues>

21
android/ShieldAI/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,220 @@
package com.shieldai.android
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import com.shieldai.android.ui.components.BadgeVariant
import com.shieldai.android.ui.components.ComponentShowcase
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldBadge
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonSize
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.ui.theme.ShieldAITheme
import org.junit.Rule
import org.junit.Test
class ComponentTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shieldButton_rendersWithText() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Click Me", onClick = {})
}
}
composeTestRule.onNodeWithText("Click Me").assertIsDisplayed()
}
@Test
fun shieldButton_clickHandlerFires() {
var clicked = false
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Click Me", onClick = { clicked = true })
}
}
composeTestRule.onNodeWithText("Click Me").performClick()
assert(clicked) { "Button click handler was not invoked" }
}
@Test
fun shieldButton_disabledDoesNotFireClick() {
var clicked = false
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Click Me", onClick = { clicked = true }, enabled = false)
}
}
composeTestRule.onNodeWithText("Click Me").performClick()
assert(!clicked) { "Disabled button should not fire click handler" }
}
@Test
fun shieldButton_showsLoadingIndicator() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Saving", onClick = {}, loading = true)
}
}
composeTestRule.onNodeWithTag("CircularProgressIndicator").assertExists()
}
@Test
fun shieldButton_variantsRender() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary)
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary)
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost)
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger)
}
}
composeTestRule.onNodeWithText("Primary").assertIsDisplayed()
composeTestRule.onNodeWithText("Secondary").assertIsDisplayed()
composeTestRule.onNodeWithText("Ghost").assertIsDisplayed()
composeTestRule.onNodeWithText("Danger").assertIsDisplayed()
}
@Test
fun shieldButton_sizesRender() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Small", onClick = {}, size = ShieldButtonSize.Small)
ShieldButton(text = "Medium", onClick = {}, size = ShieldButtonSize.Medium)
ShieldButton(text = "Large", onClick = {}, size = ShieldButtonSize.Large)
}
}
composeTestRule.onNodeWithText("Small").assertIsDisplayed()
composeTestRule.onNodeWithText("Medium").assertIsDisplayed()
composeTestRule.onNodeWithText("Large").assertIsDisplayed()
}
@Test
fun shieldButton_fullWidthRenders() {
composeTestRule.setContent {
ShieldAITheme {
ShieldButton(text = "Full Width", onClick = {}, fullWidth = true, modifier = Modifier.fillMaxWidth())
}
}
composeTestRule.onNodeWithText("Full Width").assertIsDisplayed()
}
@Test
fun shieldTextField_rendersWithLabel() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(value = "", onValueChange = {}, label = "Email")
}
}
composeTestRule.onNodeWithText("Email").assertIsDisplayed()
}
@Test
fun shieldTextField_showsErrorState() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "bad",
onValueChange = {},
label = "Input",
isError = true,
errorMessage = "Invalid input"
)
}
}
composeTestRule.onNodeWithText("Invalid input").assertIsDisplayed()
}
@Test
fun shieldTextField_helperTextDisplayed() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Input",
helperText = "Enter your name"
)
}
}
composeTestRule.onNodeWithText("Enter your name").assertIsDisplayed()
}
@Test
fun shieldTextField_passwordToggleExists() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Password",
inputType = InputType.Password
)
}
}
composeTestRule.onNodeWithText("Show").assertIsDisplayed()
}
@Test
fun shieldBadge_variantsRender() {
composeTestRule.setContent {
ShieldAITheme {
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
}
}
composeTestRule.onNodeWithText("Success").assertIsDisplayed()
composeTestRule.onNodeWithText("Error").assertIsDisplayed()
composeTestRule.onNodeWithText("Warning").assertIsDisplayed()
composeTestRule.onNodeWithText("Info").assertIsDisplayed()
composeTestRule.onNodeWithText("Default").assertIsDisplayed()
}
@Test
fun shieldTextField_acceptsInput() {
composeTestRule.setContent {
ShieldAITheme {
ShieldTextField(
value = "",
onValueChange = {},
label = "Name"
)
}
}
composeTestRule.onNodeWithText("Name").assertIsDisplayed()
}
@Test
fun componentShowcase_renders() {
composeTestRule.setContent {
ShieldAITheme {
ComponentShowcase()
}
}
composeTestRule.onNodeWithText("ShieldAI Design System").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldButton").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldCard").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldBadge").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldAvatar").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldProgressBar").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldEmptyState").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldSkeleton").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldToast").assertIsDisplayed()
composeTestRule.onNodeWithText("ShieldModal").assertIsDisplayed()
}
}

View File

@@ -0,0 +1,16 @@
package com.shieldai.android
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.shieldai.android", appContext.packageName)
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ShieldAIApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ShieldAI">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ShieldAI">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,20 @@
package com.shieldai.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.shieldai.android.navigation.AppNavigation
import com.shieldai.android.ui.theme.ShieldAITheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShieldAITheme {
AppNavigation()
}
}
}
}

View File

@@ -0,0 +1,21 @@
package com.shieldai.android
import android.app.Application
import com.shieldai.android.data.repository.AuthRepository
import com.shieldai.android.data.repository.AuthRepositoryImpl
class ShieldAIApp : Application() {
lateinit var authRepository: AuthRepository
private set
override fun onCreate() {
super.onCreate()
instance = this
authRepository = AuthRepositoryImpl(this)
}
companion object {
lateinit var instance: ShieldAIApp
private set
}
}

View File

@@ -0,0 +1,77 @@
package com.shieldai.android.data.local
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
@Serializable
data class CacheEntry<T>(
val data: T,
val cachedAt: Long = System.currentTimeMillis(),
val ttlMs: Long = CacheManager.DEFAULT_TTL_MS,
) {
fun isExpired(): Boolean = System.currentTimeMillis() - cachedAt > ttlMs
}
object CacheManager {
const val DEFAULT_TTL_MS = 5 * 60 * 1000L
private val ttlOverrides = mutableMapOf<String, Long>()
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
fun setTtl(tableName: String, ttlMs: Long) {
ttlOverrides[tableName] = ttlMs
}
fun getTtl(tableName: String): Long = ttlOverrides[tableName] ?: DEFAULT_TTL_MS
fun <T> save(context: Context, key: String, data: T) {
val entry = CacheEntry(
data = data,
cachedAt = System.currentTimeMillis(),
ttlMs = getTtl(key),
)
val file = File(context.cacheDir, "$key.cache")
file.writeText(json.encodeToString(entry))
}
@Suppress("UNCHECKED_CAST")
fun <T> load(context: Context, key: String): T? {
val file = File(context.cacheDir, "$key.cache")
if (!file.exists()) return null
return try {
val text = file.readText()
val entry = json.decodeFromString<CacheEntry<Map<String, Any>>>(text)
if (entry.isExpired()) {
file.delete()
null
} else {
json.decodeFromString<CacheEntry<T>>(text).data
}
} catch (_: Exception) {
file.delete()
null
}
}
fun clear(context: Context, key: String) {
File(context.cacheDir, "$key.cache").delete()
}
fun clearAll(context: Context) {
context.cacheDir.listFiles { _, name -> name.endsWith(".cache") }?.forEach { it.delete() }
}
fun isExpired(cachedAt: Long, tableName: String): Boolean {
val ttl = getTtl(tableName)
return System.currentTimeMillis() - cachedAt > ttl
}
fun isFresh(cachedAt: Long, tableName: String): Boolean = !isExpired(cachedAt, tableName)
fun clearOverrides() = ttlOverrides.clear()
}

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Alert(
val id: String,
val type: String,
val title: String,
val message: String,
val severity: String,
val read: Boolean = false,
val date: String? = null,
@SerialName("action_url") val actionUrl: String? = null,
@SerialName("created_at") val createdAt: String? = null,
)

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BrokerListing(
val id: String,
@SerialName("broker_name") val brokerName: String,
@SerialName("property_address") val propertyAddress: String? = null,
val url: String? = null,
val status: String = "active",
@SerialName("date_found") val dateFound: String? = null,
@SerialName("removal_request_id") val removalRequestId: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,18 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Exposure(
val id: String,
val type: String,
val source: String,
val severity: String,
val details: String? = null,
val date: String? = null,
@SerialName("watchlist_item_id") val watchlistItemId: String? = null,
val resolved: Boolean = false,
@SerialName("resolved_at") val resolvedAt: String? = null,
@SerialName("created_at") val createdAt: String? = null,
)

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Property(
val id: String,
val address: String,
val type: String,
@SerialName("owner_name") val ownerName: String? = null,
val county: String? = null,
@SerialName("document_id") val documentId: String? = null,
val status: String = "monitored",
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RemovalRequest(
val id: String,
@SerialName("listing_id") val listingId: String,
val status: String,
@SerialName("submitted_date") val submittedDate: String? = null,
@SerialName("resolved_date") val resolvedDate: String? = null,
val notes: String? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SpamRule(
val id: String,
val pattern: String,
val action: String,
val enabled: Boolean = true,
val description: String? = null,
val priority: Int = 0,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,17 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Subscription(
val id: String,
val plan: String,
val status: String,
@SerialName("start_date") val startDate: String? = null,
@SerialName("end_date") val endDate: String? = null,
val features: List<String> = emptyList(),
@SerialName("auto_renew") val autoRenew: Boolean = true,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,19 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val name: String,
val email: String,
val phone: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("subscription_tier") val subscriptionTier: String? = null,
@SerialName("email_verified") val emailVerified: Boolean = false,
@SerialName("phone_verified") val phoneVerified: Boolean = false,
@SerialName("is_new_user") val isNewUser: Boolean = false,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,14 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VoiceAnalysis(
val id: String,
@SerialName("enrollment_id") val enrollmentId: String,
val confidence: Double = 0.0,
val result: String? = null,
val status: String = "pending",
@SerialName("created_at") val createdAt: String? = null,
)

View File

@@ -0,0 +1,14 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VoiceEnrollment(
val id: String,
val name: String,
@SerialName("sample_count") val sampleCount: Int = 0,
val status: String = "pending",
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
)

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WatchlistItem(
val id: String,
val type: String,
val value: String,
val label: String? = null,
val status: String = "active",
@SerialName("date_added") val dateAdded: String? = null,
@SerialName("last_checked") val lastChecked: String? = null,
@SerialName("alerts_enabled") val alertsEnabled: Boolean = true,
)

View File

@@ -0,0 +1,31 @@
package com.shieldai.android.data.remote
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor(context: Context) : Interceptor {
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"shieldai_auth_prefs",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override fun intercept(chain: Interceptor.Chain): Response {
val token = securePrefs.getString("access_token", null)
val request = if (token != null) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,63 @@
package com.shieldai.android.data.remote
import kotlinx.coroutines.delay
import kotlin.math.min
import kotlin.math.pow
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int = -1) : ApiResult<Nothing>()
}
object ErrorHandler {
private const val MAX_RETRIES = 3
private const val BASE_DELAY_MS = 1000L
private const val MAX_DELAY_MS = 10000L
suspend fun <T> executeWithRetry(
maxRetries: Int = MAX_RETRIES,
block: suspend () -> T,
): ApiResult<T> {
var lastError: Exception? = null
for (attempt in 0..maxRetries) {
try {
val result = block()
return ApiResult.Success(result)
} catch (e: Exception) {
lastError = e
if (attempt < maxRetries && shouldRetry(e)) {
val delayMs = calculateBackoff(attempt)
delay(delayMs)
}
}
}
return ApiResult.Error(lastError?.message ?: "Unknown error")
}
private fun shouldRetry(e: Exception): Boolean {
return when {
e is java.net.SocketTimeoutException -> true
e is java.net.ConnectException -> true
e is java.net.UnknownHostException -> true
e is java.io.IOException -> true
e.message?.contains("503") == true -> true
e.message?.contains("429") == true -> true
else -> false
}
}
private fun calculateBackoff(attempt: Int): Long {
val exponential = BASE_DELAY_MS * 2.0.pow(attempt.toDouble())
return min(exponential.toLong(), MAX_DELAY_MS)
}
fun parseError(throwable: Throwable): String {
return when (throwable) {
is java.net.UnknownHostException -> "No internet connection"
is java.net.SocketTimeoutException -> "Request timed out"
is java.net.ConnectException -> "Connection refused"
is java.io.IOException -> "Network error: ${throwable.message}"
else -> throwable.message ?: "Unknown error"
}
}
}

View File

@@ -0,0 +1,78 @@
package com.shieldai.android.data.remote
import com.shieldai.android.data.model.Alert
import com.shieldai.android.data.model.BrokerListing
import com.shieldai.android.data.model.Exposure
import com.shieldai.android.data.model.Property
import com.shieldai.android.data.model.RemovalRequest
import com.shieldai.android.data.model.SpamRule
import com.shieldai.android.data.model.Subscription
import com.shieldai.android.data.model.User
import com.shieldai.android.data.model.VoiceAnalysis
import com.shieldai.android.data.model.VoiceEnrollment
import com.shieldai.android.data.model.WatchlistItem
import kotlinx.serialization.json.JsonObject
import retrofit2.http.Body
import retrofit2.http.POST
interface TRPCApiService {
@POST("api/trpc/user.me")
suspend fun userMe(@Body body: JsonObject): TRPCResponse<User>
@POST("api/trpc/user.updateProfile")
suspend fun userUpdateProfile(@Body body: JsonObject): TRPCResponse<User>
@POST("api/trpc/subscription.get")
suspend fun subscriptionGet(@Body body: JsonObject): TRPCResponse<Subscription>
@POST("api/trpc/subscription.update")
suspend fun subscriptionUpdate(@Body body: JsonObject): TRPCResponse<Subscription>
@POST("api/trpc/darkwatch.getWatchlist")
suspend fun darkWatchGetWatchlist(@Body body: JsonObject): TRPCResponse<List<WatchlistItem>>
@POST("api/trpc/darkwatch.addWatchlistItem")
suspend fun darkWatchAddWatchlistItem(@Body body: JsonObject): TRPCResponse<WatchlistItem>
@POST("api/trpc/darkwatch.removeWatchlistItem")
suspend fun darkWatchRemoveWatchlistItem(@Body body: JsonObject): TRPCResponse<Unit>
@POST("api/trpc/darkwatch.getExposures")
suspend fun darkWatchGetExposures(@Body body: JsonObject): TRPCResponse<List<Exposure>>
@POST("api/trpc/alerts.list")
suspend fun alertsList(@Body body: JsonObject): TRPCResponse<List<Alert>>
@POST("api/trpc/alerts.markRead")
suspend fun alertsMarkRead(@Body body: JsonObject): TRPCResponse<Alert>
@POST("api/trpc/voice.enrollments")
suspend fun voiceEnrollments(@Body body: JsonObject): TRPCResponse<List<VoiceEnrollment>>
@POST("api/trpc/voice.createEnrollment")
suspend fun voiceCreateEnrollment(@Body body: JsonObject): TRPCResponse<VoiceEnrollment>
@POST("api/trpc/voice.analyze")
suspend fun voiceAnalyze(@Body body: JsonObject): TRPCResponse<VoiceAnalysis>
@POST("api/trpc/spam.listRules")
suspend fun spamListRules(@Body body: JsonObject): TRPCResponse<List<SpamRule>>
@POST("api/trpc/spam.createRule")
suspend fun spamCreateRule(@Body body: JsonObject): TRPCResponse<SpamRule>
@POST("api/trpc/property.list")
suspend fun propertyList(@Body body: JsonObject): TRPCResponse<List<Property>>
@POST("api/trpc/property.add")
suspend fun propertyAdd(@Body body: JsonObject): TRPCResponse<Property>
@POST("api/trpc/removal.list")
suspend fun removalList(@Body body: JsonObject): TRPCResponse<List<RemovalRequest>>
@POST("api/trpc/removal.create")
suspend fun removalCreate(@Body body: JsonObject): TRPCResponse<RemovalRequest>
@POST("api/trpc/broker.listListings")
suspend fun brokerListListings(@Body body: JsonObject): TRPCResponse<List<BrokerListing>>
}

View File

@@ -0,0 +1,47 @@
package com.shieldai.android.data.remote
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
@Serializable
data class TRPCResponse<T>(
val result: TRPCResult<T>,
)
@Serializable
data class TRPCResult<T>(
val data: T,
)
data class TRPCErrorResponse(
val error: TRPCError,
)
data class TRPCError(
val message: String,
val code: Int = -1,
) {
companion object {
fun fromJson(json: JsonObject): TRPCError {
val errorObj = json["error"]?.jsonObject
val message = errorObj?.get("message")?.jsonPrimitive?.content ?: "Unknown error"
val code = errorObj?.get("code")?.jsonPrimitive?.content?.toIntOrNull() ?: -1
return TRPCError(message = message, code = code)
}
}
}
object TRPCRequest {
fun body(json: JsonObject): JsonObject {
return buildJsonObject {
put("0", buildJsonObject {
put("json", json)
})
}
}
}

View File

@@ -0,0 +1,47 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.Alert
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class AlertRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _alerts = MutableStateFlow<List<Alert>>(emptyList())
suspend fun getAlerts(): ApiResult<List<Alert>> {
val cached: List<Alert>? = CacheManager.load(context, "alerts")
if (cached != null) {
_alerts.value = cached
return ApiResult.Success(cached)
}
return ErrorHandler.executeWithRetry {
val response = api.alertsList(TRPCRequest.body(buildJsonObject {}))
val alerts = response.result.data
CacheManager.save(context, "alerts", alerts)
_alerts.value = alerts
alerts
}
}
suspend fun markRead(id: String): ApiResult<Alert> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("id", id) }
val response = api.alertsMarkRead(TRPCRequest.body(body))
val alert = response.result.data
_alerts.value = _alerts.value.map { if (it.id == id) alert else it }
alert
}
}
fun observeAlerts(): Flow<List<Alert>> = _alerts
}

View File

@@ -0,0 +1,162 @@
package com.shieldai.android.data.repository
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class AuthToken(
val accessToken: String,
val refreshToken: String? = null
)
data class User(
val id: String,
val name: String,
val email: String,
val isNewUser: Boolean = false
)
interface AuthRepository {
suspend fun login(email: String, password: String): Result<User>
suspend fun signup(name: String, email: String, password: String): Result<User>
suspend fun forgotPassword(email: String): Result<Unit>
suspend fun resetPassword(email: String, code: String, password: String): Result<Unit>
suspend fun signInWithGoogle(idToken: String): Result<User>
fun saveToken(accessToken: String, refreshToken: String?)
fun getAccessToken(): String?
fun getRefreshToken(): String?
fun clearTokens()
fun isLoggedIn(): Boolean
}
class AuthRepositoryImpl(
context: Context,
private val baseUrl: String = "https://api.shieldai.com"
) : AuthRepository {
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"shieldai_auth_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private fun post(path: String, body: Map<String, String>): JSONObject {
val jsonBody = JSONObject(body).toString()
val request = Request.Builder()
.url("$baseUrl$path")
.post(jsonBody.toRequestBody(JSON_MEDIA_TYPE))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: throw Exception("Empty response")
if (!response.isSuccessful) {
val errorJson = try {
JSONObject(responseBody)
} catch (_: Exception) {
null
}
val message = errorJson?.optString("message", "Request failed") ?: "Request failed"
throw Exception(message)
}
return JSONObject(responseBody)
}
override suspend fun login(email: String, password: String): Result<User> = runCatching {
val json = post("/api/auth/login", mapOf(
"email" to email,
"password" to password
))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", false)
)
}
override suspend fun signup(name: String, email: String, password: String): Result<User> = runCatching {
val json = post("/api/auth/signup", mapOf(
"name" to name,
"email" to email,
"password" to password
))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", true)
)
}
override suspend fun forgotPassword(email: String): Result<Unit> = runCatching {
post("/api/auth/forgot-password", mapOf("email" to email))
Unit
}
override suspend fun resetPassword(email: String, code: String, password: String): Result<Unit> = runCatching {
post("/api/auth/reset-password", mapOf(
"email" to email,
"code" to code,
"password" to password
))
Unit
}
override suspend fun signInWithGoogle(idToken: String): Result<User> = runCatching {
val json = post("/api/auth/google", mapOf("idToken" to idToken))
val token = json.getString("accessToken")
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
saveToken(token, refreshToken)
User(
id = json.getString("id"),
name = json.getString("name"),
email = json.getString("email"),
isNewUser = json.optBoolean("isNewUser", false)
)
}
override fun saveToken(accessToken: String, refreshToken: String?) {
securePrefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.apply()
}
override fun getAccessToken(): String? = securePrefs.getString("access_token", null)
override fun getRefreshToken(): String? = securePrefs.getString("refresh_token", null)
override fun clearTokens() {
securePrefs.edit()
.remove("access_token")
.remove("refresh_token")
.apply()
}
override fun isLoggedIn(): Boolean = getAccessToken() != null
}

View File

@@ -0,0 +1,84 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.Exposure
import com.shieldai.android.data.model.WatchlistItem
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class DarkWatchRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _watchlist = MutableStateFlow<List<WatchlistItem>>(emptyList())
suspend fun getWatchlist(forceRefresh: Boolean = false): ApiResult<List<WatchlistItem>> {
if (!forceRefresh) {
val cached: List<WatchlistItem>? = CacheManager.load(context, "watchlist")
if (cached != null) {
_watchlist.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
val items = response.result.data
CacheManager.save(context, "watchlist", items)
_watchlist.value = items
items
}
}
suspend fun addWatchlistItem(type: String, value: String, label: String? = null): ApiResult<WatchlistItem> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
put("type", type)
put("value", value)
label?.let { put("label", it) }
}
val response = api.darkWatchAddWatchlistItem(TRPCRequest.body(body))
val item = response.result.data
refreshCache()
item
}
}
suspend fun removeWatchlistItem(id: String): ApiResult<Unit> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("id", id) }
api.darkWatchRemoveWatchlistItem(TRPCRequest.body(body))
refreshCache()
}
}
suspend fun getExposures(forceRefresh: Boolean = false): ApiResult<List<Exposure>> {
if (!forceRefresh) {
val cached: List<Exposure>? = CacheManager.load(context, "exposures")
if (cached != null) return ApiResult.Success(cached)
}
return ErrorHandler.executeWithRetry {
val response = api.darkWatchGetExposures(TRPCRequest.body(buildJsonObject {}))
val exposures = response.result.data
CacheManager.save(context, "exposures", exposures)
exposures
}
}
fun observeWatchlist(): Flow<List<WatchlistItem>> = _watchlist
private suspend fun refreshCache() {
ErrorHandler.executeWithRetry {
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
val items = response.result.data
CacheManager.save(context, "watchlist", items)
_watchlist.value = items
}
}
}

View File

@@ -0,0 +1,38 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.Subscription
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class SubscriptionRepository(
private val api: TRPCApiService,
private val context: Context,
) {
suspend fun getSubscription(): ApiResult<Subscription> {
val cached: Subscription? = CacheManager.load(context, "subscription")
if (cached != null) return ApiResult.Success(cached)
return ErrorHandler.executeWithRetry {
val response = api.subscriptionGet(TRPCRequest.body(buildJsonObject {}))
val subscription = response.result.data
CacheManager.save(context, "subscription", subscription)
subscription
}
}
suspend fun updateSubscription(plan: String): ApiResult<Subscription> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("plan", plan) }
val response = api.subscriptionUpdate(TRPCRequest.body(body))
val subscription = response.result.data
CacheManager.save(context, "subscription", subscription)
subscription
}
}
}

View File

@@ -0,0 +1,52 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.User
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.buildJsonObject
class UserRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _currentUser = MutableStateFlow<User?>(null)
suspend fun getMe(forceRefresh: Boolean = false): ApiResult<User> {
if (!forceRefresh) {
val cached: User? = CacheManager.load(context, "current_user")
if (cached != null) {
_currentUser.value = cached
return ApiResult.Success(cached)
}
}
return ErrorHandler.executeWithRetry {
val response = api.userMe(TRPCRequest.body(buildJsonObject {}))
val user = response.result.data
CacheManager.save(context, "current_user", user)
_currentUser.value = user
user
}
}
suspend fun updateProfile(name: String? = null, phone: String? = null): ApiResult<User> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
name?.let { put("name", kotlinx.serialization.json.JsonPrimitive(it)) }
phone?.let { put("phone", kotlinx.serialization.json.JsonPrimitive(it)) }
}
val response = api.userUpdateProfile(TRPCRequest.body(body))
val user = response.result.data
CacheManager.save(context, "current_user", user)
_currentUser.value = user
user
}
}
fun observeCurrentUser(): Flow<User?> = _currentUser
}

View File

@@ -0,0 +1,68 @@
package com.shieldai.android.data.repository
import android.content.Context
import com.shieldai.android.data.local.CacheManager
import com.shieldai.android.data.model.VoiceAnalysis
import com.shieldai.android.data.model.VoiceEnrollment
import com.shieldai.android.data.remote.ApiResult
import com.shieldai.android.data.remote.ErrorHandler
import com.shieldai.android.data.remote.TRPCApiService
import com.shieldai.android.data.remote.TRPCRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
class VoicePrintRepository(
private val api: TRPCApiService,
private val context: Context,
) {
private val _enrollments = MutableStateFlow<List<VoiceEnrollment>>(emptyList())
suspend fun getEnrollments(): ApiResult<List<VoiceEnrollment>> {
val cached: List<VoiceEnrollment>? = CacheManager.load(context, "voice_enrollments")
if (cached != null) {
_enrollments.value = cached
return ApiResult.Success(cached)
}
return ErrorHandler.executeWithRetry {
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
val enrollments = response.result.data
CacheManager.save(context, "voice_enrollments", enrollments)
_enrollments.value = enrollments
enrollments
}
}
suspend fun createEnrollment(name: String): ApiResult<VoiceEnrollment> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject { put("name", name) }
val response = api.voiceCreateEnrollment(TRPCRequest.body(body))
val enrollment = response.result.data
refreshEnrollmentsCache()
enrollment
}
}
suspend fun analyze(enrollmentId: String, audioData: String): ApiResult<VoiceAnalysis> {
return ErrorHandler.executeWithRetry {
val body = buildJsonObject {
put("enrollmentId", enrollmentId)
put("audioData", audioData)
}
val response = api.voiceAnalyze(TRPCRequest.body(body))
response.result.data
}
}
fun observeEnrollments(): Flow<List<VoiceEnrollment>> = _enrollments
private suspend fun refreshEnrollmentsCache() {
ErrorHandler.executeWithRetry {
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
val enrollments = response.result.data
CacheManager.save(context, "voice_enrollments", enrollments)
_enrollments.value = enrollments
}
}
}

View File

@@ -0,0 +1,52 @@
package com.shieldai.android.data.sync
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class OfflineWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val queue = PendingRequestQueue(applicationContext)
val pendingRequests = queue.getAll()
if (pendingRequests.isEmpty()) return Result.success()
val client = OkHttpClient.Builder().build()
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
for (request in pendingRequests) {
if (request.retryCount >= request.maxRetries) {
queue.deleteById(request.id)
continue
}
try {
val body = request.body.toRequestBody(jsonMediaType)
val httpRequest = Request.Builder()
.url("https://api.shieldai.com/${request.endpoint}")
.method(request.method, body)
.build()
val response = client.newCall(httpRequest).execute()
if (response.isSuccessful) {
queue.deleteById(request.id)
} else {
queue.incrementRetry(request.id)
if (response.code == 422 || response.code == 400) {
queue.deleteById(request.id)
}
}
} catch (_: Exception) {
queue.incrementRetry(request.id)
return Result.retry()
}
}
queue.deleteExpired()
return if (queue.count() == 0) Result.success() else Result.retry()
}
}

View File

@@ -0,0 +1,71 @@
package com.shieldai.android.data.sync
import android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
@Serializable
data class PendingRequest(
val id: Long = 0,
val endpoint: String,
val method: String = "POST",
val body: String,
val timestamp: Long = System.currentTimeMillis(),
val retryCount: Int = 0,
val maxRetries: Int = 5,
)
class PendingRequestQueue(private val context: Context) {
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
private val file: File get() = File(context.cacheDir, "pending_requests.json")
fun getAll(): List<PendingRequest> {
if (!file.exists()) return emptyList()
return try {
json.decodeFromString<List<PendingRequest>>(file.readText())
} catch (_: Exception) {
file.delete()
emptyList()
}
}
private fun saveAll(requests: List<PendingRequest>) {
file.writeText(json.encodeToString(requests))
}
fun insert(request: PendingRequest) {
val requests = getAll().toMutableList()
val newId = (requests.maxOfOrNull { it.id } ?: 0) + 1
requests.add(request.copy(id = newId))
saveAll(requests)
}
fun incrementRetry(id: Long) {
val requests = getAll().map {
if (it.id == id) it.copy(retryCount = it.retryCount + 1) else it
}
saveAll(requests)
}
fun deleteById(id: Long) {
val requests = getAll().filter { it.id != id }
saveAll(requests)
}
fun deleteExpired() {
val requests = getAll().filter { it.retryCount < it.maxRetries }
saveAll(requests)
}
fun deleteAll() {
file.delete()
}
fun count(): Int = getAll().size
}

View File

@@ -0,0 +1,65 @@
package com.shieldai.android.data.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
class SyncManager(private val context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val queue = PendingRequestQueue(context)
fun enqueueRequest(endpoint: String, body: String, method: String = "POST") {
val request = PendingRequest(
endpoint = endpoint,
method = method,
body = body,
)
queue.insert(request)
scheduleSync()
}
fun scheduleSync(delayMinutes: Long = 0) {
val workRequest = OneTimeWorkRequestBuilder<OfflineWorker>()
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"offline_sync",
ExistingWorkPolicy.REPLACE,
workRequest,
)
}
fun queueSize(): Int = queue.count()
fun startMonitoring() {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (queueSize() > 0) {
scheduleSync()
}
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
fun isOnline(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}

View File

@@ -0,0 +1,16 @@
package com.shieldai.android.di
import android.content.Context
import com.shieldai.android.data.local.CacheManager
object DatabaseModule {
fun initializeCache(context: Context) {
CacheManager.setTtl("users", 5 * 60 * 1000L)
CacheManager.setTtl("current_user", 5 * 60 * 1000L)
CacheManager.setTtl("watchlist", 5 * 60 * 1000L)
CacheManager.setTtl("exposures", 5 * 60 * 1000L)
CacheManager.setTtl("alerts", 5 * 60 * 1000L)
CacheManager.setTtl("subscription", 5 * 60 * 1000L)
CacheManager.setTtl("voice_enrollments", 10 * 60 * 1000L)
}
}

View File

@@ -0,0 +1,61 @@
package com.shieldai.android.di
import android.content.Context
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.shieldai.android.data.remote.AuthInterceptor
import com.shieldai.android.data.remote.TRPCApiService
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
object NetworkModule {
private var baseUrl: String = "http://10.0.2.2:3000/"
private var retrofit: Retrofit? = null
private var apiService: TRPCApiService? = null
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
fun setBaseUrl(url: String) {
baseUrl = if (url.endsWith("/")) url else "$url/"
retrofit = null
apiService = null
}
fun getBaseUrl(): String = baseUrl
fun provideOkHttpClient(context: Context): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(context))
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
fun provideRetrofit(context: Context): Retrofit {
return retrofit ?: synchronized(this) {
retrofit ?: Retrofit.Builder()
.baseUrl(baseUrl)
.client(provideOkHttpClient(context))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.also { retrofit = it }
}
}
fun provideApiService(context: Context): TRPCApiService {
return apiService ?: synchronized(this) {
apiService ?: provideRetrofit(context).create(TRPCApiService::class.java)
.also { apiService = it }
}
}
}

View File

@@ -0,0 +1,61 @@
package com.shieldai.android.di
import android.content.Context
import com.shieldai.android.data.repository.AlertRepository
import com.shieldai.android.data.repository.DarkWatchRepository
import com.shieldai.android.data.repository.SubscriptionRepository
import com.shieldai.android.data.repository.UserRepository
import com.shieldai.android.data.repository.VoicePrintRepository
object RepositoryModule {
private var userRepository: UserRepository? = null
private var darkWatchRepository: DarkWatchRepository? = null
private var voicePrintRepository: VoicePrintRepository? = null
private var alertRepository: AlertRepository? = null
private var subscriptionRepository: SubscriptionRepository? = null
fun provideUserRepository(context: Context): UserRepository {
return userRepository ?: synchronized(this) {
UserRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { userRepository = it }
}
}
fun provideDarkWatchRepository(context: Context): DarkWatchRepository {
return darkWatchRepository ?: synchronized(this) {
DarkWatchRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { darkWatchRepository = it }
}
}
fun provideVoicePrintRepository(context: Context): VoicePrintRepository {
return voicePrintRepository ?: synchronized(this) {
VoicePrintRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { voicePrintRepository = it }
}
}
fun provideAlertRepository(context: Context): AlertRepository {
return alertRepository ?: synchronized(this) {
AlertRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { alertRepository = it }
}
}
fun provideSubscriptionRepository(context: Context): SubscriptionRepository {
return subscriptionRepository ?: synchronized(this) {
SubscriptionRepository(
api = NetworkModule.provideApiService(context),
context = context,
).also { subscriptionRepository = it }
}
}
}

View File

@@ -0,0 +1,75 @@
package com.shieldai.android.navigation
import android.app.Application
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun AppNavigation() {
val context = LocalContext.current
val app = context.applicationContext as ShieldAIApp
val viewModel: AuthViewModel = viewModel(
factory = AuthViewModel.Factory
)
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
val isNewUser by viewModel.isNewUser.collectAsState()
if (isAuthenticated) {
if (isNewUser) {
OnboardingNavHost(
viewModel = viewModel,
onComplete = {
viewModel.completeOnboarding()
}
)
} else {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val bottomNavScreens = setOf(
Screen.Dashboard.route,
Screen.Services.route,
Screen.Alerts.route,
Screen.Settings.route,
Screen.Account.route
)
val showBottomBar = currentRoute in bottomNavScreens
Scaffold(
bottomBar = {
if (showBottomBar) {
BottomNavBar(
currentRoute = currentRoute,
onNavigate = { screen ->
navController.navigate(screen.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
restoreState = true
}
}
)
}
}
) { innerPadding ->
NavGraph(
navController = navController,
viewModel = viewModel,
modifier = Modifier.padding(innerPadding)
)
}
}
} else {
AuthNavHost(viewModel = viewModel)
}
}

View File

@@ -0,0 +1,41 @@
package com.shieldai.android.navigation
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import com.shieldai.android.R
data class BottomNavItem(
val screen: Screen,
val label: String,
val icon: ImageVector
)
@Composable
fun BottomNavBar(
currentRoute: String?,
onNavigate: (Screen) -> Unit
) {
val items = listOf(
BottomNavItem(Screen.Dashboard, "Dashboard", ImageVector.vectorResource(R.drawable.ic_dashboard)),
BottomNavItem(Screen.Services, "Services", ImageVector.vectorResource(R.drawable.ic_services)),
BottomNavItem(Screen.Alerts, "Alerts", ImageVector.vectorResource(R.drawable.ic_alerts)),
BottomNavItem(Screen.Settings, "Settings", ImageVector.vectorResource(R.drawable.ic_settings)),
BottomNavItem(Screen.Account, "Account", ImageVector.vectorResource(R.drawable.ic_account_box))
)
NavigationBar {
items.forEach { item ->
NavigationBarItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = currentRoute == item.screen.route,
onClick = { onNavigate(item.screen) }
)
}
}
}

View File

@@ -0,0 +1,121 @@
package com.shieldai.android.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.shieldai.android.ui.screens.auth.AuthScreen
import com.shieldai.android.ui.screens.auth.ForgotPasswordScreen
import com.shieldai.android.ui.screens.auth.ResetPasswordScreen
import com.shieldai.android.ui.screens.onboarding.OnboardingScreen
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun NavGraph(
navController: NavHostController,
viewModel: AuthViewModel,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Screen.Dashboard.route,
modifier = modifier
) {
composable(Screen.Dashboard.route) {
PlaceholderScreen(title = "Dashboard")
}
composable(Screen.Services.route) {
PlaceholderScreen(title = "Services")
}
composable(Screen.Alerts.route) {
PlaceholderScreen(title = "Alerts")
}
composable(Screen.Settings.route) {
PlaceholderScreen(title = "Settings")
}
composable(Screen.Account.route) {
PlaceholderScreen(title = "Account")
}
composable(
route = Screen.ServiceDetail.ROUTE,
arguments = listOf(navArgument("serviceId") { type = NavType.StringType })
) { backStackEntry ->
val serviceId = backStackEntry.arguments?.getString("serviceId") ?: ""
PlaceholderScreen(title = "Service: $serviceId")
}
}
}
@Composable
fun AuthNavHost(viewModel: AuthViewModel) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.Auth.route
) {
composable(Screen.Auth.route) {
AuthScreen(
viewModel = viewModel,
onNavigateToForgotPassword = {
navController.navigate(Screen.ForgotPassword.route)
},
onNavigateToResetPassword = {
navController.navigate(Screen.ResetPassword.createRoute(""))
}
)
}
composable(Screen.ForgotPassword.route) {
ForgotPasswordScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ResetPassword.route,
arguments = listOf(navArgument("email") { type = NavType.StringType; defaultValue = "" })
) { backStackEntry ->
val email = backStackEntry.arguments?.getString("email") ?: ""
ResetPasswordScreen(
viewModel = viewModel,
email = email,
onBack = { navController.popBackStack(Screen.Auth.route, false) }
)
}
}
}
@Composable
fun OnboardingNavHost(
viewModel: AuthViewModel,
onComplete: () -> Unit
) {
OnboardingScreen(
viewModel = viewModel,
onComplete = onComplete
)
}
@Composable
private fun PlaceholderScreen(title: String) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground
)
}
}

View File

@@ -0,0 +1,21 @@
package com.shieldai.android.navigation
sealed class Screen(val route: String) {
data object Dashboard : Screen("dashboard")
data object Services : Screen("services")
data object Alerts : Screen("alerts")
data object Settings : Screen("settings")
data object Account : Screen("account")
data object Auth : Screen("auth")
data object ForgotPassword : Screen("forgot_password")
data object ResetPassword : Screen("reset_password/{email}") {
fun createRoute(email: String) = "reset_password/$email"
}
data object Onboarding : Screen("onboarding")
data class ServiceDetail(val serviceId: String) : Screen("service_detail/{serviceId}") {
companion object {
const val ROUTE = "service_detail/{serviceId}"
fun createRoute(serviceId: String) = "service_detail/$serviceId"
}
}
}

View File

@@ -0,0 +1,269 @@
package com.shieldai.android.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.ShieldAITheme
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComponentShowcase(modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var textFieldValue by remember { mutableStateOf("") }
var showSheet by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "ShieldAI Design System",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
SectionTitle("ShieldButton")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary, size = ShieldButtonSize.Small)
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary, size = ShieldButtonSize.Small)
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost, size = ShieldButtonSize.Small)
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger, size = ShieldButtonSize.Small)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldButton(text = "Loading", onClick = {}, loading = true)
ShieldButton(text = "Disabled", onClick = {}, enabled = false)
}
ShieldButton(text = "Full Width", onClick = {}, variant = ShieldButtonVariant.Primary, fullWidth = true)
HorizontalDivider()
SectionTitle("ShieldCard")
ShieldCard(
modifier = Modifier.fillMaxWidth(),
header = { Text("Card Header", style = MaterialTheme.typography.titleMedium) },
footer = {
ShieldButton(text = "Action", onClick = {}, size = ShieldButtonSize.Small)
},
content = {
Spacer(modifier = Modifier.height(8.dp))
Text("This is the card content area. It uses a gradient background matching the web theme.", style = MaterialTheme.typography.bodyMedium)
}
)
HorizontalDivider()
SectionTitle("ShieldTextField")
ShieldTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
label = "Email",
placeholder = "Enter your email",
inputType = InputType.Email
)
ShieldTextField(
value = "",
onValueChange = {},
label = "Password",
inputType = InputType.Password
)
ShieldTextField(
value = "invalid",
onValueChange = {},
label = "With Error",
isError = true,
errorMessage = "This field is required"
)
HorizontalDivider()
SectionTitle("ShieldBadge")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
}
HorizontalDivider()
SectionTitle("ShieldAvatar")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ShieldAvatar(imageUrl = null, name = "John Doe", size = AvatarSize.Small)
ShieldAvatar(imageUrl = null, name = "Jane Smith", size = AvatarSize.Medium, isOnline = true)
ShieldAvatar(imageUrl = null, name = "Alice", size = AvatarSize.Large, isOnline = true)
}
HorizontalDivider()
SectionTitle("ShieldProgressBar")
ShieldProgressBar(progress = 0.3f, color = ProgressColor.Primary, showPercentage = true)
ShieldProgressBar(progress = 0.6f, color = ProgressColor.Accent, showPercentage = true)
ShieldProgressBar(progress = 0.9f, color = ProgressColor.Success, showPercentage = true)
HorizontalDivider()
SectionTitle("ShieldEmptyState")
ShieldEmptyState(
title = "No items found",
description = "Try adjusting your search or filters to find what you're looking for.",
actionButton = {
ShieldButton(text = "Clear Filters", onClick = {}, variant = ShieldButtonVariant.Secondary)
}
)
HorizontalDivider()
SectionTitle("ShieldSkeleton")
ShieldSkeletonCard(modifier = Modifier.fillMaxWidth(), lines = 3)
HorizontalDivider()
SectionTitle("ShieldToast")
ShieldButton(
text = "Show Success Toast",
onClick = {
scope.launch {
snackbarHostState.showSnackbar(
ShieldSnackbarVisuals(
message = "Operation completed successfully!",
variant = ToastVariant.Success,
duration = SnackbarDuration.Short
)
)
}
},
variant = ShieldButtonVariant.Primary,
fullWidth = true
)
ShieldButton(
text = "Show Error Toast with Action",
onClick = {
scope.launch {
snackbarHostState.showSnackbar(
ShieldSnackbarVisuals(
message = "Something went wrong.",
actionLabel = "Retry",
variant = ToastVariant.Error,
duration = SnackbarDuration.Long
)
)
}
},
variant = ShieldButtonVariant.Danger,
fullWidth = true
)
HorizontalDivider()
SectionTitle("ShieldModal")
ShieldButton(
text = "Show Bottom Sheet",
onClick = { showSheet = true },
variant = ShieldButtonVariant.Secondary,
fullWidth = true
)
ShieldButton(
text = "Show Alert Dialog",
onClick = { showDialog = true },
variant = ShieldButtonVariant.Ghost,
fullWidth = true
)
Spacer(modifier = Modifier.height(80.dp))
}
ShieldToastHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
if (showSheet) {
ShieldBottomSheet(
onDismiss = { showSheet = false },
title = "Bottom Sheet Title",
actions = listOf(
ModalAction(text = "Save", onClick = { showSheet = false }, isPrimary = true),
ModalAction(text = "Cancel", onClick = { showSheet = false })
)
) {
Text("This is the bottom sheet content area.")
}
}
if (showDialog) {
ShieldAlertDialog(
onDismiss = { showDialog = false },
onConfirm = { showDialog = false },
title = "Confirm Action",
message = "Are you sure you want to proceed?",
confirmText = "Yes, Continue",
dismissText = "Cancel"
)
}
}
}
@Composable
private fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
@Preview(showBackground = true, name = "Light Mode")
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode")
@Composable
fun ComponentShowcasePreview() {
ShieldAITheme {
ComponentShowcase()
}
}

View File

@@ -0,0 +1,97 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Success
enum class AvatarSize(val dimension: Dp, val fontSize: TextUnit) {
Small(32.dp, 12.sp),
Medium(40.dp, 16.sp),
Large(56.dp, 24.sp)
}
@Composable
fun ShieldAvatar(
imageUrl: String?,
name: String,
modifier: Modifier = Modifier,
size: AvatarSize = AvatarSize.Medium,
isOnline: Boolean = false
) {
val initials = remember(name) {
name.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
}
val statusDotSize = (size.dimension / 4).coerceAtLeast(8.dp)
Box(
modifier = modifier.size(size.dimension),
contentAlignment = Alignment.BottomEnd
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = name,
modifier = Modifier
.size(size.dimension)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.size(size.dimension)
.clip(CircleShape)
.background(BrandPrimary),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = Color.White,
fontSize = size.fontSize,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
}
}
if (isOnline) {
Canvas(
modifier = Modifier.size(statusDotSize)
) {
val radius = statusDotSize.toPx() / 2
drawCircle(
color = Color.White,
radius = radius
)
drawCircle(
color = Success,
radius = radius - 1.5.dp.toPx()
)
}
}
}
}

View File

@@ -0,0 +1,92 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Info
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.TextPrimaryLight
import com.shieldai.android.ui.theme.TextSecondaryLight
import com.shieldai.android.ui.theme.Warning
enum class BadgeVariant {
Default, Success, Warning, Error, Info
}
data class BadgeColors(
val background: Color,
val content: Color
)
fun badgeColors(variant: BadgeVariant): BadgeColors = when (variant) {
BadgeVariant.Default -> BadgeColors(
background = Color(0xFFF1F5F9),
content = TextSecondaryLight
)
BadgeVariant.Success -> BadgeColors(
background = Success.copy(alpha = 0.15f),
content = Success
)
BadgeVariant.Warning -> BadgeColors(
background = Warning.copy(alpha = 0.15f),
content = Warning
)
BadgeVariant.Error -> BadgeColors(
background = Error.copy(alpha = 0.15f),
content = Error
)
BadgeVariant.Info -> BadgeColors(
background = Info.copy(alpha = 0.15f),
content = Info
)
}
@Composable
fun ShieldBadge(
text: String,
modifier: Modifier = Modifier,
variant: BadgeVariant = BadgeVariant.Default,
icon: Painter? = null
) {
val colors = badgeColors(variant)
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = colors.background,
contentColor = colors.content
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = colors.content
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = text,
style = MaterialTheme.typography.labelSmall
)
}
}
}

View File

@@ -0,0 +1,146 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Error
enum class ShieldButtonVariant {
Primary, Secondary, Ghost, Danger
}
enum class ShieldButtonSize {
Small, Medium, Large
}
@Composable
fun ShieldButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
variant: ShieldButtonVariant = ShieldButtonVariant.Primary,
size: ShieldButtonSize = ShieldButtonSize.Medium,
enabled: Boolean = true,
loading: Boolean = false,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
fullWidth: Boolean = false
) {
val buttonModifier = if (fullWidth) modifier.fillMaxWidth() else modifier
val sizeModifier = when (size) {
ShieldButtonSize.Small -> Modifier.height(32.dp)
ShieldButtonSize.Medium -> Modifier.height(40.dp)
ShieldButtonSize.Large -> Modifier.height(48.dp)
}
val paddingModifier = when (size) {
ShieldButtonSize.Small -> Modifier.padding(horizontal = 12.dp)
ShieldButtonSize.Medium -> Modifier.padding(horizontal = 16.dp)
ShieldButtonSize.Large -> Modifier.padding(horizontal = 20.dp)
}
val indicatorSize = when (size) {
ShieldButtonSize.Small -> 16.dp
ShieldButtonSize.Medium -> 20.dp
ShieldButtonSize.Large -> 24.dp
}
val contentColor = when {
variant == ShieldButtonVariant.Ghost -> BrandPrimary
variant == ShieldButtonVariant.Secondary -> BrandPrimary
else -> Color.White
}
val containerColor = when (variant) {
ShieldButtonVariant.Primary -> BrandPrimary
ShieldButtonVariant.Danger -> Error
else -> Color.Transparent
}
val mergedEnabled = enabled && !loading
val content: @Composable RowScope.() -> Unit = {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(indicatorSize),
color = if (variant == ShieldButtonVariant.Ghost || variant == ShieldButtonVariant.Secondary)
BrandPrimary else Color.White,
strokeWidth = 2.dp
)
} else {
leadingIcon?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
style = when (size) {
ShieldButtonSize.Small -> MaterialTheme.typography.labelSmall
ShieldButtonSize.Medium -> MaterialTheme.typography.labelLarge
ShieldButtonSize.Large -> MaterialTheme.typography.titleSmall
}
)
trailingIcon?.let {
Spacer(modifier = Modifier.width(8.dp))
it()
}
}
}
when (variant) {
ShieldButtonVariant.Primary, ShieldButtonVariant.Danger -> {
Button(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor.copy(alpha = 0.4f),
disabledContentColor = contentColor.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
content = content
)
}
ShieldButtonVariant.Secondary -> {
OutlinedButton(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = BrandPrimary,
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
),
border = ButtonDefaults.outlinedButtonBorder(enabled = mergedEnabled),
shape = MaterialTheme.shapes.small,
content = content
)
}
ShieldButtonVariant.Ghost -> {
TextButton(
onClick = onClick,
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
enabled = mergedEnabled,
colors = ButtonDefaults.textButtonColors(
contentColor = BrandPrimary,
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
),
shape = MaterialTheme.shapes.small,
content = content
)
}
}
}

View File

@@ -0,0 +1,55 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandAccent
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.OutlineLight
val GradientCardBrush = Brush.linearGradient(
colors = listOf(
BrandPrimary.copy(alpha = 0.08f),
BrandAccent.copy(alpha = 0.05f)
)
)
@Composable
fun ShieldCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
header: @Composable ColumnScope.() -> Unit = {},
footer: @Composable ColumnScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
Card(
onClick = onClick ?: {},
enabled = onClick != null,
modifier = modifier,
shape = MaterialTheme.shapes.large,
border = BorderStroke(1.dp, OutlineLight),
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
)
) {
Column(
modifier = Modifier
.background(GradientCardBrush)
.padding(16.dp)
) {
header()
content()
footer()
}
}
}

View File

@@ -0,0 +1,62 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun ShieldEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
icon: Painter? = null,
actionButton: @Composable (() -> Unit)? = null
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
}
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (actionButton != null) {
Spacer(modifier = Modifier.height(24.dp))
actionButton()
}
}
}

View File

@@ -0,0 +1,125 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
data class ModalAction(
val text: String,
val onClick: () -> Unit,
val isPrimary: Boolean = false
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShieldBottomSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
title: String? = null,
actions: List<ModalAction> = emptyList(),
content: @Composable () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Spacer(modifier = Modifier.height(16.dp))
}
content()
if (actions.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
actions.forEach { action ->
TextButton(
onClick = action.onClick,
modifier = Modifier.fillMaxWidth(),
colors = if (action.isPrimary) {
ButtonDefaults.textButtonColors(contentColor = BrandPrimary)
} else {
ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
}
) {
Text(text = action.text)
}
}
}
}
}
}
@Composable
fun ShieldAlertDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
title: String,
message: String,
confirmText: String = "Confirm",
dismissText: String = "Cancel",
isDestructive: Boolean = false
) {
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = confirmText,
color = if (isDestructive) MaterialTheme.colorScheme.error else BrandPrimary
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = dismissText)
}
},
shape = MaterialTheme.shapes.large
)
}

View File

@@ -0,0 +1,66 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandAccent
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.OutlineLight
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.Warning
enum class ProgressColor {
Primary, Accent, Success, Warning, Error
}
@Composable
fun ShieldProgressBar(
progress: Float,
modifier: Modifier = Modifier,
color: ProgressColor = ProgressColor.Primary,
showPercentage: Boolean = false
) {
val progressColor = when (color) {
ProgressColor.Primary -> BrandPrimary
ProgressColor.Accent -> BrandAccent
ProgressColor.Success -> Success
ProgressColor.Warning -> Warning
ProgressColor.Error -> Error
}
Column(modifier = modifier.fillMaxWidth()) {
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = progressColor,
trackColor = OutlineLight,
strokeCap = StrokeCap.Round
)
if (showPercentage) {
Text(
text = "${(progress * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
textAlign = TextAlign.End
)
}
}
}

View File

@@ -0,0 +1,122 @@
package com.shieldai.android.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.OutlineLight
@Composable
fun ShieldSkeletonLine(
modifier: Modifier = Modifier,
widthFraction: Float = 1f
) {
val shimmerColors = listOf(
OutlineLight.copy(alpha = 0.6f),
Color.White.copy(alpha = 0.4f),
OutlineLight.copy(alpha = 0.6f)
)
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnimation by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerOffset"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnimation - 200f, 0f),
end = Offset(translateAnimation, 0f)
)
Box(
modifier = modifier
.fillMaxWidth(fraction = widthFraction)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(brush)
)
}
@Composable
fun ShieldSkeletonRectangle(
modifier: Modifier = Modifier,
height: Int = 100
) {
val shimmerColors = listOf(
OutlineLight.copy(alpha = 0.6f),
Color.White.copy(alpha = 0.4f),
OutlineLight.copy(alpha = 0.6f)
)
val transition = rememberInfiniteTransition(label = "shimmerRect")
val translateAnimation by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerRectOffset"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnimation - 200f, 0f),
end = Offset(translateAnimation, 0f)
)
Box(
modifier = modifier
.fillMaxWidth()
.height(height.dp)
.clip(RoundedCornerShape(8.dp))
.background(brush)
)
}
@Composable
fun ShieldSkeletonCard(
modifier: Modifier = Modifier,
lines: Int = 3
) {
Column(modifier = modifier.padding(16.dp)) {
ShieldSkeletonRectangle(height = 120)
Spacer(modifier = Modifier.height(12.dp))
repeat(lines) { index ->
ShieldSkeletonLine(
widthFraction = when (index) {
0 -> 0.9f
lines - 1 -> 0.5f
else -> 0.75f
}
)
if (index < lines - 1) {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}

View File

@@ -0,0 +1,118 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
enum class InputType {
Text, Email, Password, Number, Phone
}
@Composable
fun ShieldTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String = "",
inputType: InputType = InputType.Text,
isError: Boolean = false,
errorMessage: String? = null,
helperText: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false
) {
var passwordVisible by remember { mutableStateOf(false) }
val keyboardType = when (inputType) {
InputType.Email -> KeyboardType.Email
InputType.Password -> KeyboardType.Password
InputType.Number -> KeyboardType.Number
InputType.Phone -> KeyboardType.Phone
else -> KeyboardType.Text
}
val visualTransformation = if (inputType == InputType.Password && !passwordVisible) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
}
val trailingIcon = if (inputType == InputType.Password) {
@Composable {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Text(
text = if (passwordVisible) "Hide" else "Show",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
} else null
Column(modifier = modifier.fillMaxWidth()) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = if (placeholder.isNotEmpty()) {{ Text(placeholder) }} else null,
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
readOnly = readOnly,
singleLine = true,
isError = isError,
visualTransformation = visualTransformation,
trailingIcon = trailingIcon,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Next
),
shape = MaterialTheme.shapes.medium,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = MaterialTheme.colorScheme.primary,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline,
errorIndicatorColor = Error,
focusedLabelColor = MaterialTheme.colorScheme.primary,
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary
)
)
if (isError && errorMessage != null) {
Text(
text = errorMessage,
color = Error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
} else if (helperText != null) {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}

View File

@@ -0,0 +1,92 @@
package com.shieldai.android.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Info
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.TextPrimaryDark
import com.shieldai.android.ui.theme.Warning
enum class ToastVariant {
Success, Error, Warning, Info
}
data class ToastColors(
val container: Color,
val content: Color,
val action: Color
)
fun toastColors(variant: ToastVariant): ToastColors = when (variant) {
ToastVariant.Success -> ToastColors(
container = Success,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Error -> ToastColors(
container = Error,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Warning -> ToastColors(
container = Warning,
content = TextPrimaryDark,
action = TextPrimaryDark
)
ToastVariant.Info -> ToastColors(
container = Info,
content = TextPrimaryDark,
action = TextPrimaryDark
)
}
@Composable
fun ShieldToastHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
SnackbarHost(
hostState = hostState,
modifier = modifier,
snackbar = { data: SnackbarData ->
val visuals = data.visuals as? ShieldSnackbarVisuals
val colors = visuals?.let { toastColors(it.variant) }
?: toastColors(ToastVariant.Info)
Snackbar(
snackbarData = data,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
containerColor = colors.container,
contentColor = colors.content,
actionColor = colors.action
)
}
)
}
class ShieldSnackbarVisuals(
message: String,
actionLabel: String? = null,
duration: SnackbarDuration = SnackbarDuration.Short,
val variant: ToastVariant = ToastVariant.Info
) : SnackbarVisuals {
override val message: String = message
override val actionLabel: String? = actionLabel
override val duration: SnackbarDuration = duration
override val withDismissAction: Boolean = false
}

View File

@@ -0,0 +1,112 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.R
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun AuthScreen(
viewModel: AuthViewModel,
onNavigateToForgotPassword: () -> Unit,
onNavigateToResetPassword: () -> Unit
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("Login", "Sign Up")
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(64.dp))
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "ShieldAI Logo",
modifier = Modifier.size(72.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "ShieldAI",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Your all-in-one digital protection",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
TabRow(
selectedTabIndex = selectedTab,
modifier = Modifier.fillMaxWidth()
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
ShieldCard(
modifier = Modifier.fillMaxWidth()
) {
if (selectedTab == 0) {
LoginScreen(
viewModel = viewModel,
onNavigateToForgotPassword = onNavigateToForgotPassword,
uiState = uiState
)
} else {
SignupScreen(
viewModel = viewModel,
uiState = uiState
)
}
}
}
}
}

View File

@@ -0,0 +1,89 @@
package com.shieldai.android.ui.screens.auth
import android.content.Context
import android.security.identity.IdentityCredentialException
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
@Composable
fun BiometricAuthScreen(
onAuthenticated: () -> Unit,
onError: (String) -> Unit,
title: String = "Biometric Authentication",
subtitle: String = "Authenticate to access ShieldAI",
description: String = "Use your fingerprint or face to sign in"
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
val biometricManager = remember {
BiometricManager.from(context)
}
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
val canAuthenticate = biometricManager.canAuthenticate(authenticators)
val promptInfo = remember {
BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setAllowedAuthenticators(authenticators)
.build()
}
DisposableEffect(activity) {
if (activity != null && canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) {
val biometricPrompt = BiometricPrompt(
activity,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onAuthenticated()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
errorCode != BiometricPrompt.ERROR_USER_CANCELED
) {
onError(errString.toString())
}
}
override fun onAuthenticationFailed() {
onError("Authentication failed")
}
}
)
biometricPrompt.authenticate(promptInfo)
}
onDispose { }
}
}
fun canUseBiometric(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
return biometricManager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun isBiometricEnabled(context: Context): Boolean {
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean("biometric_enabled", false)
}
fun setBiometricEnabled(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
prefs.edit().putBoolean("biometric_enabled", enabled).apply()
}

View File

@@ -0,0 +1,139 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun ForgotPasswordScreen(
viewModel: AuthViewModel,
onBack: () -> Unit
) {
var email by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
if (uiState.forgotPasswordSent) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Check Your Email",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "We've sent password reset instructions to $email",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}
} else {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Reset Password",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter your email address and we'll send you instructions to reset your password.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Send Reset Instructions",
onClick = { viewModel.forgotPassword(email) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = email.isNotBlank(),
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
variant = ShieldButtonVariant.Ghost,
fullWidth = true
)
}
}
}
}
}
}

View File

@@ -0,0 +1,183 @@
package com.shieldai.android.ui.screens.auth
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.viewmodel.AuthUiState
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
onNavigateToForgotPassword: () -> Unit,
uiState: AuthUiState
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var rememberMe by remember { mutableStateOf(false) }
val context = LocalContext.current
val gso = remember {
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(context.getString(com.shieldai.android.R.string.default_web_client_id))
.requestEmail()
.build()
}
val googleSignInClient: GoogleSignInClient = remember {
GoogleSignIn.getClient(context, gso)
}
val googleSignInLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
try {
val account = task.getResult(ApiException::class.java)
account.idToken?.let { token ->
viewModel.signInWithGoogle(token)
}
} catch (_: ApiException) { }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = password,
onValueChange = { password = it },
label = "Password",
inputType = InputType.Password,
placeholder = "Enter your password"
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = rememberMe,
onCheckedChange = { rememberMe = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Remember me",
style = MaterialTheme.typography.bodySmall
)
}
TextButton(onClick = onNavigateToForgotPassword) {
Text(
text = "Forgot password?",
style = MaterialTheme.typography.bodySmall,
color = BrandPrimary
)
}
}
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Sign In",
onClick = { viewModel.login(email, password) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "or continue with",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = {
val signInIntent = googleSignInClient.signInIntent
googleSignInLauncher.launch(signInIntent)
},
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface)
) {
Text(
text = "Sign in with Google",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
}
}
}

View File

@@ -0,0 +1,151 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldCard
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun ResetPasswordScreen(
viewModel: AuthViewModel,
email: String,
onBack: () -> Unit
) {
var code by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
val uiState by viewModel.uiState.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
if (uiState.resetPasswordSuccess) {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Password Reset Successful",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your password has been reset. You can now log in with your new password.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
ShieldButton(
text = "Back to Login",
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}
} else {
ShieldCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Set New Password",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Enter the reset code sent to your email and your new password.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
ShieldTextField(
value = code,
onValueChange = { code = it },
label = "Reset Code",
placeholder = "Enter the code from email"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = newPassword,
onValueChange = { newPassword = it },
label = "New Password",
inputType = InputType.Password,
placeholder = "Enter new password"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "Confirm New Password",
inputType = InputType.Password,
placeholder = "Re-enter new password",
isError = confirmPassword.isNotEmpty() && newPassword != confirmPassword,
errorMessage = if (confirmPassword.isNotEmpty() && newPassword != confirmPassword) "Passwords do not match" else null
)
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Reset Password",
onClick = { viewModel.resetPassword(email, code, newPassword) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = code.isNotBlank() && newPassword.isNotBlank()
&& newPassword == confirmPassword,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.shieldai.android.ui.screens.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ProgressColor
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldProgressBar
import com.shieldai.android.ui.components.ShieldTextField
import com.shieldai.android.util.PasswordStrength
import com.shieldai.android.util.calculatePasswordStrength
import com.shieldai.android.util.passwordStrengthLabel
import com.shieldai.android.viewmodel.AuthUiState
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun SignupScreen(
viewModel: AuthViewModel,
uiState: AuthUiState
) {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var acceptTerms by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
ShieldTextField(
value = name,
onValueChange = { name = it },
label = "Full Name",
placeholder = "John Doe"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = email,
onValueChange = { email = it },
label = "Email",
inputType = InputType.Email,
placeholder = "you@example.com"
)
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = password,
onValueChange = {
password = it
viewModel.updatePasswordStrength(it)
},
label = "Password",
inputType = InputType.Password,
placeholder = "Create a strong password"
)
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
val strength = calculatePasswordStrength(password)
ShieldProgressBar(
progress = when (strength) {
PasswordStrength.WEAK -> 0.25f
PasswordStrength.FAIR -> 0.5f
PasswordStrength.STRONG -> 0.75f
PasswordStrength.VERY_STRONG -> 1.0f
},
color = when (strength) {
PasswordStrength.WEAK -> ProgressColor.Error
PasswordStrength.FAIR -> ProgressColor.Warning
PasswordStrength.STRONG -> ProgressColor.Success
PasswordStrength.VERY_STRONG -> ProgressColor.Success
},
showPercentage = false
)
Text(
text = "Password strength: ${passwordStrengthLabel(strength)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(12.dp))
ShieldTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = "Confirm Password",
inputType = InputType.Password,
placeholder = "Re-enter your password",
isError = confirmPassword.isNotEmpty() && password != confirmPassword,
errorMessage = if (confirmPassword.isNotEmpty() && password != confirmPassword) "Passwords do not match" else null
)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptTerms,
onCheckedChange = { acceptTerms = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "I accept the Terms of Service and Privacy Policy",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(16.dp))
ShieldButton(
text = "Create Account",
onClick = { viewModel.signup(name, email, password) },
modifier = Modifier.fillMaxWidth(),
loading = uiState.isLoading,
enabled = name.isNotBlank() && email.isNotBlank() && password.isNotBlank()
&& password == confirmPassword && acceptTerms,
fullWidth = true
)
if (uiState.error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = uiState.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}

View File

@@ -0,0 +1,135 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.ui.theme.Success
@Composable
fun CompleteStep(onComplete: () -> Unit) {
val animatedProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
animatedProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 1000)
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
) {
Canvas(
modifier = Modifier.size(120.dp)
) {
val strokeWidth = 8.dp.toPx()
val radius = (size.minDimension - strokeWidth) / 2
val center = Offset(size.width / 2, size.height / 2)
drawCircle(
color = Color.LightGray.copy(alpha = 0.3f),
radius = radius,
center = center,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
drawArc(
color = Success,
startAngle = -90f,
sweepAngle = 360f * animatedProgress.value,
useCenter = false,
topLeft = Offset(center.x - radius, center.y - radius),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2),
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
if (animatedProgress.value >= 0.5f) {
val checkProgress = (animatedProgress.value - 0.5f) * 2f
val startX = center.x - radius * 0.35f
val midX = center.x - radius * 0.05f
val endX = center.x + radius * 0.5f
val startY = center.y
val midY = center.y + radius * 0.35f * checkProgress
val endY = center.y - radius * 0.3f * checkProgress
drawLine(
color = Success,
start = Offset(startX, startY),
end = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
strokeWidth = 6.dp.toPx(),
cap = StrokeCap.Round
)
if (animatedProgress.value >= 0.75f) {
val endCheckProgress = (animatedProgress.value - 0.75f) * 4f
drawLine(
color = Success,
start = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
end = Offset(
startX + (endX - startX) * endCheckProgress * 0.5f + radius * 0.5f * endCheckProgress,
endY.coerceAtLeast(startY - radius * 0.3f * endCheckProgress)
),
strokeWidth = 6.dp.toPx(),
cap = StrokeCap.Round
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "You're All Set!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your account is ready. Start monitoring your digital footprint and stay protected.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(40.dp))
ShieldButton(
text = "Get Started",
onClick = onComplete,
modifier = Modifier.fillMaxWidth(),
fullWidth = true
)
}
}

View File

@@ -0,0 +1,132 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
@Composable
fun FamilyInviteStep(
invites: List<String>,
onAddInvite: (String) -> Unit,
onRemoveInvite: (Int) -> Unit
) {
var emailInput by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Invite Family Members",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Protect your family too. Add their emails to include them in your plan.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
ShieldTextField(
value = emailInput,
onValueChange = { emailInput = it },
label = "Family Email",
inputType = InputType.Email,
placeholder = "family@example.com",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
ShieldButton(
text = "Invite",
onClick = {
if (emailInput.isNotBlank()) {
onAddInvite(emailInput.trim())
emailInput = ""
}
},
variant = ShieldButtonVariant.Primary,
enabled = emailInput.isNotBlank(),
modifier = Modifier.padding(top = 6.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (invites.isEmpty()) {
Text(
text = "No invites sent yet. You can skip this step and invite later.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
)
} else {
invites.forEachIndexed { index, email ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = email,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onRemoveInvite(index) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "You can always invite more family members later from Settings.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@@ -0,0 +1,120 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
import com.shieldai.android.viewmodel.AuthViewModel
@Composable
fun OnboardingScreen(
viewModel: AuthViewModel,
onComplete: () -> Unit
) {
val pagerState = rememberPagerState(pageCount = { 4 })
val onboardingData by viewModel.onboardingData.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { page ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
when (page) {
0 -> PlanSelectionStep(
selectedPlan = onboardingData.selectedPlan,
onPlanSelected = { plan ->
viewModel.updateOnboardingData {
it.copy(selectedPlan = plan)
}
}
)
1 -> WatchlistSetupStep(
watchlistItems = onboardingData.watchlistItems,
onAddItem = { item ->
viewModel.updateOnboardingData {
it.copy(watchlistItems = it.watchlistItems + item)
}
},
onRemoveItem = { index ->
viewModel.updateOnboardingData {
it.copy(watchlistItems = it.watchlistItems.toMutableList().apply { removeAt(index) })
}
}
)
2 -> FamilyInviteStep(
invites = onboardingData.familyInvites,
onAddInvite = { email ->
viewModel.updateOnboardingData {
it.copy(familyInvites = it.familyInvites + email)
}
},
onRemoveInvite = { index ->
viewModel.updateOnboardingData {
it.copy(familyInvites = it.familyInvites.toMutableList().apply { removeAt(index) })
}
}
)
3 -> CompleteStep(onComplete = onComplete)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
repeat(4) { index ->
val isSelected = pagerState.currentPage == index
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(if (isSelected) 10.dp else 8.dp)
.clip(CircleShape)
.drawBehind {
drawCircle(
color = if (isSelected) BrandPrimary
else Color.Gray.copy(alpha = 0.3f)
)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,181 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.theme.BrandPrimary
data class Plan(
val name: String,
val price: String,
val features: List<String>,
val description: String
)
private val plans = listOf(
Plan(
name = "Basic",
price = "Free",
features = listOf(
"Monitor 1 email/phone",
"Basic alerts",
"7-day data history"
),
description = "Essential protection"
),
Plan(
name = "Plus",
price = "$9.99/mo",
features = listOf(
"Monitor up to 5 emails/phones",
"Real-time alerts",
"30-day data history",
"Family sharing (2 members)"
),
description = "Enhanced protection"
),
Plan(
name = "Premium",
price = "$19.99/mo",
features = listOf(
"Unlimited monitoring",
"Priority alerts",
"90-day data history",
"Family sharing (5 members)",
"Dark web monitoring",
"Identity restoration support"
),
description = "Maximum protection"
)
)
@Composable
fun PlanSelectionStep(
selectedPlan: String,
onPlanSelected: (String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Choose Your Plan",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Select the plan that fits your needs. You can upgrade or change anytime.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
plans.forEach { plan ->
val isSelected = selectedPlan == plan.name
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable { onPlanSelected(plan.name) },
shape = MaterialTheme.shapes.medium,
border = BorderStroke(
width = if (isSelected) 2.dp else 1.dp,
color = if (isSelected) BrandPrimary else MaterialTheme.colorScheme.outline
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
BrandPrimary.copy(alpha = 0.08f)
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
RadioButton(
selected = isSelected,
onClick = { onPlanSelected(plan.name) },
colors = RadioButtonDefaults.colors(
selectedColor = BrandPrimary
)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = plan.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = plan.price,
style = MaterialTheme.typography.titleMedium,
color = BrandPrimary,
fontWeight = FontWeight.Bold
)
}
Text(
text = plan.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
plan.features.forEach { feature ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "",
color = BrandPrimary,
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = feature,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,123 @@
package com.shieldai.android.ui.screens.onboarding
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.shieldai.android.ui.components.InputType
import com.shieldai.android.ui.components.ShieldButton
import com.shieldai.android.ui.components.ShieldButtonVariant
import com.shieldai.android.ui.components.ShieldTextField
@Composable
fun WatchlistSetupStep(
watchlistItems: List<String>,
onAddItem: (String) -> Unit,
onRemoveItem: (Int) -> Unit
) {
var inputValue by remember { mutableStateOf("") }
var inputType by remember { mutableStateOf(InputType.Email) }
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = "Set Up Your Watchlist",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add email addresses or phone numbers you want to monitor for breaches and leaks.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
ShieldTextField(
value = inputValue,
onValueChange = { inputValue = it },
label = "Email or Phone",
inputType = inputType,
placeholder = "you@example.com or +1234567890",
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
ShieldButton(
text = "Add",
onClick = {
if (inputValue.isNotBlank()) {
onAddItem(inputValue.trim())
inputValue = ""
}
},
variant = ShieldButtonVariant.Primary,
enabled = inputValue.isNotBlank(),
modifier = Modifier.padding(top = 6.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (watchlistItems.isEmpty()) {
Text(
text = "No items added yet. Add emails or phones to start monitoring.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
)
} else {
watchlistItems.forEachIndexed { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = item,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onRemoveItem(index) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}

View File

@@ -0,0 +1,37 @@
package com.shieldai.android.ui.theme
import androidx.compose.ui.graphics.Color
val BrandPrimary = Color(0xFF4F46E5)
val BrandPrimaryLight = Color(0xFF818CF8)
val BrandPrimaryDark = Color(0xFF3730A3)
val BrandAccent = Color(0xFF06B6D4)
val BrandAccentLight = Color(0xFF67E8F9)
val BrandAccentDark = Color(0xFF0891B2)
val BgPrimaryLight = Color(0xFFFFFFFF)
val BgSecondaryLight = Color(0xFFF8FAFC)
val BgTertiaryLight = Color(0xFFF1F5F9)
val BgPrimaryDark = Color(0xFF0F172A)
val BgSecondaryDark = Color(0xFF1E293B)
val BgTertiaryDark = Color(0xFF334155)
val TextPrimaryLight = Color(0xFF0F172A)
val TextSecondaryLight = Color(0xFF475569)
val TextTertiaryLight = Color(0xFF94A3B8)
val TextPrimaryDark = Color(0xFFF1F5F9)
val TextSecondaryDark = Color(0xFF94A3B8)
val TextTertiaryDark = Color(0xFF64748B)
val Success = Color(0xFF22C55E)
val Warning = Color(0xFFF59E0B)
val Error = Color(0xFFEF4444)
val Info = Color(0xFF3B82F6)
val SurfaceLight = Color(0xFFFFFFFF)
val SurfaceDark = Color(0xFF1E293B)
val OutlineLight = Color(0xFFE2E8F0)
val OutlineDark = Color(0xFF475569)

View File

@@ -0,0 +1,11 @@
package com.shieldai.android.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp)
)

View File

@@ -0,0 +1,80 @@
package com.shieldai.android.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColorScheme = lightColorScheme(
primary = BrandPrimary,
onPrimary = BgPrimaryLight,
primaryContainer = BrandPrimaryLight,
onPrimaryContainer = BgPrimaryLight,
secondary = BrandAccent,
onSecondary = BgPrimaryLight,
secondaryContainer = BrandAccentLight,
onSecondaryContainer = BgPrimaryDark,
tertiary = BrandPrimaryDark,
onTertiary = BgPrimaryLight,
background = BgPrimaryLight,
onBackground = TextPrimaryLight,
surface = SurfaceLight,
onSurface = TextPrimaryLight,
surfaceVariant = BgSecondaryLight,
onSurfaceVariant = TextSecondaryLight,
outline = OutlineLight,
outlineVariant = BgTertiaryLight,
error = Error,
onError = BgPrimaryLight
)
private val DarkColorScheme = darkColorScheme(
primary = BrandPrimaryLight,
onPrimary = BgPrimaryDark,
primaryContainer = BrandPrimary,
onPrimaryContainer = TextPrimaryDark,
secondary = BrandAccentLight,
onSecondary = BgPrimaryDark,
secondaryContainer = BrandAccent,
onSecondaryContainer = TextPrimaryDark,
tertiary = BrandPrimaryDark,
onTertiary = TextPrimaryDark,
background = BgPrimaryDark,
onBackground = TextPrimaryDark,
surface = SurfaceDark,
onSurface = TextPrimaryDark,
surfaceVariant = BgSecondaryDark,
onSurfaceVariant = TextSecondaryDark,
outline = OutlineDark,
outlineVariant = BgTertiaryDark,
error = Error,
onError = BgPrimaryDark
)
@Composable
fun ShieldAITheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}

View File

@@ -0,0 +1,109 @@
package com.shieldai.android.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,48 @@
package com.shieldai.android.util
import androidx.compose.ui.graphics.Color
import com.shieldai.android.ui.theme.Error
import com.shieldai.android.ui.theme.Success
import com.shieldai.android.ui.theme.Warning
enum class PasswordStrength {
WEAK, FAIR, STRONG, VERY_STRONG
}
fun calculatePasswordStrength(password: String): PasswordStrength {
if (password.length < 6) return PasswordStrength.WEAK
var score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (password.any { it.isUpperCase() }) score++
if (password.any { it.isLowerCase() }) score++
if (password.any { it.isDigit() }) score++
if (password.any { !it.isLetterOrDigit() }) score++
return when {
score <= 2 -> PasswordStrength.WEAK
score == 3 -> PasswordStrength.FAIR
score == 4 -> PasswordStrength.STRONG
else -> PasswordStrength.VERY_STRONG
}
}
fun passwordStrengthProgress(strength: PasswordStrength): Float = when (strength) {
PasswordStrength.WEAK -> 0.25f
PasswordStrength.FAIR -> 0.5f
PasswordStrength.STRONG -> 0.75f
PasswordStrength.VERY_STRONG -> 1.0f
}
fun passwordStrengthLabel(strength: PasswordStrength): String = when (strength) {
PasswordStrength.WEAK -> "Weak"
PasswordStrength.FAIR -> "Fair"
PasswordStrength.STRONG -> "Strong"
PasswordStrength.VERY_STRONG -> "Very Strong"
}
fun passwordStrengthColor(strength: PasswordStrength): Color = when (strength) {
PasswordStrength.WEAK -> Error
PasswordStrength.FAIR -> Warning
PasswordStrength.STRONG -> Success
PasswordStrength.VERY_STRONG -> Success
}

View File

@@ -0,0 +1,196 @@
package com.shieldai.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shieldai.android.ShieldAIApp
import com.shieldai.android.data.repository.AuthRepository
import com.shieldai.android.data.repository.AuthRepositoryImpl
import com.shieldai.android.data.repository.User
import com.shieldai.android.util.calculatePasswordStrength
import com.shieldai.android.util.passwordStrengthProgress
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class AuthUiState(
val isLoading: Boolean = false,
val error: String? = null,
val user: User? = null,
val forgotPasswordSent: Boolean = false,
val resetPasswordSuccess: Boolean = false,
val passwordStrength: Float = 0f
)
data class OnboardingData(
val selectedPlan: String = "Basic",
val watchlistItems: List<String> = emptyList(),
val familyInvites: List<String> = emptyList()
)
class AuthViewModel(
private val repository: AuthRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
private val _isAuthenticated = MutableStateFlow(repository.isLoggedIn())
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
private val _isNewUser = MutableStateFlow(false)
val isNewUser: StateFlow<Boolean> = _isNewUser.asStateFlow()
private val _onboardingData = MutableStateFlow(OnboardingData())
val onboardingData: StateFlow<OnboardingData> = _onboardingData.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.login(email, password)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Login failed"
)
}
)
}
}
fun signup(name: String, email: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.signup(name, email, password)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Signup failed"
)
}
)
}
}
fun forgotPassword(email: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, forgotPasswordSent = false)
val result = repository.forgotPassword(email)
result.fold(
onSuccess = {
_uiState.value = _uiState.value.copy(isLoading = false, forgotPasswordSent = true)
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Request failed"
)
}
)
}
}
fun resetPassword(email: String, code: String, password: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, resetPasswordSuccess = false)
val result = repository.resetPassword(email, code, password)
result.fold(
onSuccess = {
_uiState.value = _uiState.value.copy(isLoading = false, resetPasswordSuccess = true)
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Reset failed"
)
}
)
}
}
fun signInWithGoogle(idToken: String) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val result = repository.signInWithGoogle(idToken)
result.fold(
onSuccess = { user ->
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
_isAuthenticated.value = true
_isNewUser.value = user.isNewUser
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Google Sign-In failed"
)
}
)
}
}
fun logout() {
repository.clearTokens()
_uiState.value = AuthUiState()
_isAuthenticated.value = false
_isNewUser.value = false
_onboardingData.value = OnboardingData()
}
fun updatePasswordStrength(password: String) {
val strength = calculatePasswordStrength(password)
_uiState.value = _uiState.value.copy(
passwordStrength = passwordStrengthProgress(strength)
)
}
fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
fun updateOnboardingData(update: (OnboardingData) -> OnboardingData) {
_onboardingData.value = update(_onboardingData.value)
}
fun completeOnboarding() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
val data = _onboardingData.value
try {
repository.saveToken(
repository.getAccessToken() ?: throw Exception("Not authenticated"),
repository.getRefreshToken()
)
_isNewUser.value = false
_uiState.value = _uiState.value.copy(isLoading = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to complete onboarding"
)
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val app = ShieldAIApp.instance
return AuthViewModel(app.authRepository) as T
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More