diff --git a/.gitignore b/.gitignore index d8b62a43..bdd0afb9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,10 @@ Rev.md .DS_Store Thumbs.db MESHIFY_KEYSTORE_BASE64.txt +.agents +AGENT.md +AGENTS.md +GEMINI.md +*.salive gha-creds-*.json diff --git a/AGENT.md b/AGENT.md deleted file mode 100644 index 91dc404d..00000000 --- a/AGENT.md +++ /dev/null @@ -1,351 +0,0 @@ -# MESHIFY PROJECT CONTEXT - -> **Project**: Meshify - Offline P2P Messaging - - This entire project is built using LLMs. - - This app is for personal use only, so performance and speed should be prioritized over security, but clean code is essential. - - The current project is a Git repo, so you can look at the latest commits to understand what changes have occurred. -> **Type**: Android Application (Kotlin + Jetpack Compose + Hilt) -> **Architecture**: Clean Architecture + MVVM + Multi-Module - ---- - -## PROJECT OVERVIEW - -Meshify is a decentralized, offline-first P2P messaging application that enables communication between Android devices on the same local network without requiring internet connectivity or central servers. - -### Core Value Proposition -- **Simple & Lightweight**: Plaintext messages over LAN with zero encryption overhead -- **Offline-Ready**: Works on local networks (WiFi) without internet access -- **Clean Architecture**: Modular, testable, and maintainable codebase -- **Localization**: Full English and Arabic (RTL) support - -### Key Features -- 1-on-1 plaintext text messaging -- File attachments (images, videos, documents) -- Message replies, reactions, delete, forward -- mDNS/NSD peer discovery -- LAN TCP transport with connection pooling -- Material 3 UI -- Room offline database with pagination -- UUID-based peer identification (SimplePeerIdProvider) - ---- - -## ARCHITECTURE - -### Module Dependency Graph - -``` -:app - ├── :core:common # Utilities (Logger, FileUtils, ImageCompressor) - ├── :core:data # Room DB, DataStore, Repository implementations - ├── :core:domain # Pure Kotlin: Interfaces, Models, Use Cases - ├── :core:network # mDNS, Sockets, LAN/BLE Transport - └── :core:ui # Material 3 Components, Theme, Shared UI - ├── :feature:home # Recent chats screen - ├── :feature:chat # Chat conversation screen - ├── :feature:discovery # Device discovery - └── :feature:settings # Settings & customization -``` - -### Dependency Rules -``` -:app → :feature:* → :core:* -:feature:* → :core:* (NEVER other feature modules) -:core:* → :core:domain (domain has ZERO dependencies) -``` - -### Architectural Layers - -**Presentation Layer** (`:feature:*`, `:core:ui`) -- Jetpack Compose UI with Material 3 -- ViewModels for state management -- Hilt for dependency injection - -**Domain Layer** (`:core:domain`) -- Pure Kotlin (no Android/framework dependencies) -- Repository interfaces (`IChatRepository`, `ISettingsRepository`) -- Domain models and use cases -- Security models (`SecurityEvent`, `MessageEnvelope`, `OobVerificationMethod`) - -**Data Layer** (`:core:data`) -- Room database (plain SQLite, no encryption) -- DataStore for preferences -- Repository implementations -- LRU caching and pagination - -**Network Layer** (`:core:network`) -- LAN Transport: TCP sockets with connection pooling -- BLE Transport: Bluetooth Low Energy (in progress) -- mDNS/NSD service discovery -- Keep-alive and health monitoring - ---- - -## TECH STACK - -### Core Technologies -| Technology | Version | Purpose | -|------------|---------|---------| -| **Kotlin** | 2.3.10 | Primary language | -| **AGP** | 9.1.0 | Android Gradle Plugin | -| **Jetpack Compose** | 2026.02.00 (BOM) | UI toolkit | -| **Material 3** | 1.4.0-alpha10 | Design system | - -### Data & State -| Library | Version | Purpose | -|---------|---------|---------| -| **Room** | 2.8.4 | Local database | -| **DataStore** | 1.1.1 | Preferences storage | -| **Paging 3** | 3.3.5 | Data pagination | -| **Navigation** | 2.9.7 | In-app navigation | -| **Hilt** | 2.59 | Dependency injection | - -### Networking & Media -| Library | Version | Purpose | -|---------|---------|---------| -| **Media3** | 1.8.0 | Media playback | -| **Coil 3** | 3.4.0 | Image loading | - -### Build & Testing -| Tool | Purpose | -|------|---------| -| **KSP** | Annotation processing (Room, Hilt) | -| **JUnit 4** | Unit testing | -| **MockK** | Mocking framework | -| **Turbine** | Flow testing | -| **Robolectric** | Android unit testing | -| **Espresso** | UI testing | - ---- - -## BUILD & RUN COMMANDS - -### Prerequisites -- **JDK**: 21 (configured via SDKMAN: `/home/youusef/.sdkman/candidates/java/21-librca`) -- **Android SDK**: API 26+ (Min), API 35 (Target) -- **Gradle JVM Args**: `-Xmx4096m` (configured in `gradle.properties`) - -### Build Commands - -```bash -# Clean build -./gradlew clean - -# Build debug APK -./gradlew assembleDebug - -# Build release APK -./gradlew assembleRelease - -# Run all tests -./gradlew test - -# Run unit tests only -./gradlew testDebugUnitTest - -# Run instrumented tests -./gradlew connectedAndroidTest - -# Lint check -./gradlew lint - -# Format Kotlin code -./gradlew ktlintFormat -``` - -### Development Commands - -```bash -# Run app on connected device/emulator -./gradlew installDebug - -# View build scan -./gradlew build --scan - -# Check dependency updates -./gradlew dependencyUpdates -``` - ---- - -## KEY DIRECTORIES - -``` -Meshify/ -├── app/ # Main application module -│ ├── src/main/java/com/p2p/meshify/ -│ │ ├── MeshifyApp.kt # Application class (Hilt setup) -│ │ ├── MainActivity.kt # Main activity (Compose host) -│ │ ├── service/ # Foreground services -│ │ └── receivers/ # Broadcast receivers -│ ├── schemas/ # Room database schemas -│ └── build.gradle.kts # App-level build config -├── core/ -│ ├── common/ # Shared utilities -│ ├── data/ # Data layer (Room, DataStore, Repos) -│ ├── domain/ # Domain layer (Interfaces, Models) -│ │ ├── model/ # Domain models (PeerDevice, Payload, etc.) -│ │ ├── repository/ # Repository interfaces -│ │ ├── security/ # Crypto interfaces & models -│ │ └── usecase/ # Use cases -│ ├── network/ # Network layer -│ │ ├── base/ # Transport interfaces -│ │ ├── lan/ # LAN TCP implementation -│ │ └── ble/ # BLE implementation -│ └── ui/ # Shared UI components & theme -├── feature/ -│ ├── home/ # Recent chats screen -│ ├── chat/ # Chat conversation screen -│ ├── discovery/ # Peer discovery screen -│ ├── settings/ # Settings screen -│ ├── onboarding/ # Welcome flow -│ └── help/ # FAQ & About screens -├── gradle/ -│ └── libs.versions.toml # Version catalog -├── docs/ # Documentation local only (this file in the .gitignore) -└── QWEN.md # This file (AI assistant context) (this file in the .gitignore) -``` - ---- - -## SECURITY ARCHITECTURE - -### Message Format — Plaintext -- **MessageEnvelope**: Simple data class with `senderId`, `recipientId`, `text`, `timestamp`, `messageType` -- **Serialization**: Binary ByteBuffer with length-prefixed fields (efficient, compact) -- **No encryption**: Messages travel as plaintext over LAN -- **No authentication**: No ECDSA signatures, no TOFU, no key exchange - -### Database -- **Plain SQLite**: No SQLCipher, no encryption -- **Room**: Version 7 (migrated from v6 by dropping `trusted_peers` table) -- **Key Management**: None required - -### Network -- **No Central Server**: Pure P2P architecture -- **Local Network Only**: No internet dependency -- **Connection Pooling**: Pre-warmed, monitored connections -- **Handshake V3**: Exchanges peer name only (no crypto fields) - -### Peer Identity -- **SimplePeerIdProvider**: UUID generated once, stored in SharedPreferences -- **No Keystore**: No EC keys, no certificates, no Android Keystore -- **No Biometric**: No fingerprint/face authentication - -### Removed Security Infrastructure (5 phases, ~8,300 lines) -All of the following have been permanently removed: -- ~~SQLCipher~~ (Phase 1) -- ~~Android Keystore PeerIdentity~~ (Phase 2) -- ~~ECDH key exchange (EcdhSessionManager)~~ (Phase 3) -- ~~AES-256-GCM encryption (MessageEnvelopeCrypto)~~ (Phase 3) -- ~~HKDF key derivation (HkdfKeyDerivation)~~ (Phase 3) -- ~~Nonce cache replay protection~~ (Phase 3) -- ~~EncryptedSessionKeyStore~~ (Phase 3) -- ~~TOFU trust model (PeerTrustStore, TrustedPeer, TrustLevel)~~ (Phase 4) -- ~~Security events (DecryptionFailed, TofuViolation, SessionExpired)~~ (Phase 4) -- ~~Tink, BouncyCastle, Security Crypto, Biometric dependencies~~ (Phase 5) - ---- - -## UI/UX CONVENTIONS - -### Material 3 -- Uses `androidx.compose.material3:material3:1.4.0-alpha10` -- Opt-in: `@OptIn(ExperimentalMaterial3ExpressiveApi::class)` -- Motion presets: Gentle, Standard, Snappy, Bouncy - -### Theme System -- **Modes**: Light, Dark, System -- **Dynamic Colors**: Material You support -- **Custom Seed Colors**: User-selectable accent colors -- **Persistence**: Stored via DataStore - -### Localization -- **Languages**: English, Arabic -- **RTL Support**: Full right-to-left layout support -- **String Resources**: All UI strings in `strings.xml` -- **Google Fonts**: For typography - -## UI/UX CONVENTIONS (FIDGET TOY PHILOSOPHY) - -### Core Philosophy -- **The "Fidget Toy" Feel:** Every user interaction MUST have an immediate, satisfying, and physically plausible reaction. The app should feel alive, tactile, and playful. -- **Zero Latency Illusion:** Since the app is offline-first, UI updates must be instant. Never block UI animations waiting for background tasks. - -### Design Language & Shapes -- **Standard:** Material You 3 Expressive (M3E) / Android 16 design language. -- **Strict Shape Rules:** - - **NEVER** use perfect `CircleShape` or hard sharp corners. - - **ALWAYS** use **Squircles** or highly rounded squares (e.g., `RoundedCornerShape(24.dp)` or `28.dp` for large elements, `16.dp` for smaller ones). - - **Shape Morphing:** Interactive elements MUST animate their corner radius or border thickness on press/hover (e.g., a button morphs from `24.dp` to `32.dp` when pressed down). - ---- - -### Key Optimizations -- BufferedOutputStream for file transfer -- WebP image compression -- Parallel file transfer -- Connection pooling -- ArrayDeque for messages (O(1) prepend) -- LRU cache for attachments -- `derivedStateOf` in Compose -- Stable LazyColumn keys -- Flow `.distinctUntilChanged` - ---- - -## TESTING STRATEGY - -### Current Status -- **Unit tests**: Minimal (needs coverage) -- **UI tests**: Not implemented -- **Test infrastructure**: JUnit, MockK, Turbine, Robolectric configured - -### Testing Conventions -- **Unit Tests**: `/src/test/java/` (JUnit 4 + MockK) -- **Instrumented Tests**: `/src/androidTest/java/` (AndroidX Test + Espresso) -- **Flow Testing**: Use Turbine for Kotlin Flow streams -- **Coroutines**: Use `kotlinx-coroutines-test` for dispatcher control - -### Test Targets -- Repository implementations -- ViewModel state management -- UI component rendering -- Integration tests (network + DB) -- Message serialization/deserialization - ---- - - -## DEVELOPMENT CONVENTIONS - -### Kotlin Coding Style -- Use meaningful variable and function names -- Add KDoc comments for public APIs -- Prefer `val` over `var` (immutability first) -- Use data classes for models -- Seal classes for state modeling - -### Compose Best Practices -- Use `remember` and `derivedStateOf` for performance -- Stable keys for LazyColumn items -- Hoist state to ViewModels -- Use `LaunchedEffect` for side effects -- Avoid recomposition loops - ---- - -## CONFIGURATION FILES - -| File | Purpose | -|------|---------| -| `build.gradle.kts` | Root build configuration | -| `settings.gradle.kts` | Module includes and repository config | -| `gradle.properties` | Gradle JVM args, Kotlin settings | -| `gradle/libs.versions.toml` | Version catalog for dependencies | -| `app/build.gradle.kts` | App-level build config (signing, features) | -| `app/proguard-rules.pro` | R8/ProGuard rules | -| `app/src/main/AndroidManifest.xml` | Android manifest (permissions, components) | - - diff --git a/app/src/main/java/com/p2p/meshify/MainActivity.kt b/app/src/main/java/com/p2p/meshify/MainActivity.kt index dd54cfdd..3ecc04f9 100644 --- a/app/src/main/java/com/p2p/meshify/MainActivity.kt +++ b/app/src/main/java/com/p2p/meshify/MainActivity.kt @@ -2,6 +2,7 @@ package com.p2p.meshify import android.Manifest import android.content.pm.PackageManager +import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.WindowManager @@ -25,13 +26,10 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController import com.p2p.meshify.core.util.Logger -import com.p2p.meshify.domain.model.FontFamilyPreset -import com.p2p.meshify.domain.model.MotionPreset + import com.p2p.meshify.service.MeshForegroundService -import com.p2p.meshify.core.ui.components.PremiumNoiseTexture import com.p2p.meshify.core.ui.navigation.MeshifyNavHost import com.p2p.meshify.core.ui.navigation.Screen -import com.p2p.meshify.core.ui.theme.MD3EFontFamilies import com.p2p.meshify.core.ui.theme.MeshifyTheme import com.p2p.meshify.core.ui.hooks.rememberPremiumHaptics import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics @@ -48,12 +46,13 @@ import com.p2p.meshify.feature.settings.DeveloperViewModel import com.p2p.meshify.feature.realdevicetesting.ui.RealDeviceTestingViewModel import com.p2p.meshify.feature.realdevicetesting.ui.RealDeviceTestScreen import com.p2p.meshify.feature.onboarding.WelcomeScreen +import com.p2p.meshify.feature.onboarding.PermissionStatus +import com.p2p.meshify.feature.onboarding.PermissionRequestResult import com.p2p.meshify.feature.onboarding.WelcomeViewModel import com.p2p.meshify.feature.onboarding.PermissionSummaryDialog import com.p2p.meshify.feature.onboarding.PermissionRequestCard import com.p2p.meshify.feature.onboarding.SkipConfirmationDialog import com.p2p.meshify.feature.onboarding.PermissionDefinitions -import com.p2p.meshify.feature.onboarding.PermissionRequestResult import com.p2p.meshify.core.domain.interfaces.WifiStateChecker import com.p2p.meshify.core.data.local.MeshifyDatabase import androidx.hilt.navigation.compose.hiltViewModel @@ -120,12 +119,32 @@ class MainActivity : ComponentActivity() { onboardingPermissionLauncher.launch(permissions.toTypedArray()) } + private fun applyLocale(language: String) { + val locale = java.util.Locale.forLanguageTag(language) + java.util.Locale.setDefault(locale) + val config = Configuration(resources.configuration) + config.setLocale(locale) + resources.updateConfiguration(config, resources.displayMetrics) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() val app = application as MeshifyApp + // Apply stored locale before setContent so strings render in the right language. + // Use lifecycleScope.launch instead of runBlocking to avoid blocking the main thread. + lifecycleScope.launch { + try { + val lang = app.settingsRepository.appLanguage.first() + applyLocale(lang) + } catch (e: Exception) { + Logger.e("MainActivity -> Failed to load language", e) + // Default locale will be used as fallback + } + } + // Only request permissions immediately if onboarding was already completed. // Otherwise, permissions will be requested after the onboarding flow. lifecycleScope.launch { @@ -149,12 +168,6 @@ class MainActivity : ComponentActivity() { val settingsRepo = app.settingsRepository val themeMode by settingsRepo.themeMode.collectAsState(initial = com.p2p.meshify.domain.repository.ThemeMode.SYSTEM) val dynamicColor by settingsRepo.dynamicColorEnabled.collectAsState(initial = true) - val motionPreset by settingsRepo.motionPreset.collectAsState(initial = MotionPreset.STANDARD) - val motionScale by settingsRepo.motionScale.collectAsState(initial = 1.0f) - val fontFamilyPreset by settingsRepo.fontFamilyPreset.collectAsState(initial = FontFamilyPreset.ROBOTO) - val shapeStyle by settingsRepo.shapeStyle.collectAsState(initial = com.p2p.meshify.domain.model.ShapeStyle.CIRCLE) - val bubbleStyle by settingsRepo.bubbleStyle.collectAsState(initial = com.p2p.meshify.domain.model.BubbleStyle.ROUNDED) - val visualDensity by settingsRepo.visualDensity.collectAsState(initial = 1.0f) val seedColorInt by settingsRepo.seedColor.collectAsState(initial = 0xFF006D68.toInt()) var isReady by remember { mutableStateOf(false) } @@ -184,12 +197,6 @@ class MainActivity : ComponentActivity() { MeshifyTheme( themeMode = themeMode.name, dynamicColor = dynamicColor, - motionPreset = motionPreset, - motionScale = motionScale, - fontFamily = MD3EFontFamilies.getFontFamily(fontFamilyPreset), - shapeStyle = shapeStyle, - bubbleStyle = bubbleStyle, - visualDensity = visualDensity, seedColor = seedColor ) { CompositionLocalProvider(LocalPremiumHaptics provides premiumHaptics) { @@ -212,9 +219,6 @@ class MainActivity : ComponentActivity() { Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) } else { Box(modifier = Modifier.fillMaxSize()) { - // High-end tactile feel - PremiumNoiseTexture(alpha = 0.03f) - Surface( modifier = Modifier.fillMaxSize(), color = Color.Transparent @@ -252,7 +256,7 @@ class MainActivity : ComponentActivity() { DiscoveryScreen( viewModel = discoveryViewModel, onPeerClick = { peer -> navController.navigate(Screen.Chat(peer.id, peer.name)) }, - onSettingsClick = { navController.navigate(Screen.Settings) } + onBackClick = { navController.popBackStack() } ) }, onChatRoute = { peerId, peerName -> @@ -413,7 +417,7 @@ private fun OnboardingRoute( val context = LocalContext.current // Language: "en" or "ar" - var currentLang by remember { mutableStateOf("en") } + val currentLang by settingsRepository.appLanguage.collectAsState(initial = "en") // Permission flow state var isPermissionFlowActive by remember { mutableStateOf(false) } @@ -450,71 +454,88 @@ private fun OnboardingRoute( } } - // WelcomeScreen - WelcomeScreen( - viewModel = onboardingViewModel, - currentLang = currentLang, - onLangChange = { newLang -> - currentLang = newLang - }, - onNextClick = { - // Page 3 "Get Started" → start permission flow - isPermissionFlowActive = true - currentPermissionIndex = 0 - onboardingViewModel.startPermissionFlow() - }, - onSkipClick = { - if (isPermissionFlowActive) { - showSkipConfirm = true - } else { + Box(modifier = Modifier.fillMaxSize()) { + WelcomeScreen( + viewModel = onboardingViewModel, + currentLang = currentLang, + onLangChange = { newLang -> scope.launch { - settingsRepository.setOnboardingCompleted() + settingsRepository.setAppLanguage(newLang) + activity.recreate() + } + }, + permissionStatuses = permissions.associate { + val res = permissionResults[it.id] + it.id to when (res) { + PermissionRequestResult.Granted -> PermissionStatus.Granted + PermissionRequestResult.Denied -> PermissionStatus.Denied + PermissionRequestResult.DeniedPermanently -> PermissionStatus.DeniedPermanently + else -> PermissionStatus.NotAsked + } + }, + onNextClick = { + // Page 3 "Get Started" → start permission flow + isPermissionFlowActive = true + currentPermissionIndex = 0 + }, + onSkipClick = { + if (isPermissionFlowActive) { + showSkipConfirm = true + } else { + scope.launch { + settingsRepository.setOnboardingCompleted() + } + onNavigateToHome() } - onNavigateToHome() } - } - ) + ) - // Auto-advance after permission result - LaunchedEffect(advanceTrigger) { - if (advanceTrigger > 0) { - kotlinx.coroutines.delay(PERMISSION_EXIT_ANIMATION_DELAY_MS) - currentPermissionIndex++ - } - } + // Permission flow: show cards one by one + if (isPermissionFlowActive && currentPermissionIndex < permissions.size) { + val perm = permissions[currentPermissionIndex] - // Permission flow: show cards one by one - if (isPermissionFlowActive && currentPermissionIndex < permissions.size) { - val perm = permissions[currentPermissionIndex] + // Check if already granted + val alreadyGranted = perm.androidPermissions.all { pid -> + android.content.pm.PackageManager.PERMISSION_GRANTED == + context.checkSelfPermission(pid) + } - // Check if already granted - val alreadyGranted = perm.androidPermissions.all { pid -> - android.content.pm.PackageManager.PERMISSION_GRANTED == - context.checkSelfPermission(pid) + if (alreadyGranted) { + LaunchedEffect(perm.id) { + permissionResults[perm.id] = PermissionRequestResult.Granted + advanceTrigger++ + } + } else { + PermissionRequestCard( + permission = perm, + onAllowClick = { + onRequestPermissions(perm.androidPermissions) + }, + onDenyClick = { + permissionResults[perm.id] = PermissionRequestResult.Denied + advanceTrigger++ + }, + onRequestDismiss = { + isPermissionFlowActive = false + showSummaryDialog = true + } + ) + } } - if (alreadyGranted) { - LaunchedEffect(perm.id) { - permissionResults[perm.id] = PermissionRequestResult.Granted - advanceTrigger++ - kotlinx.coroutines.delay(PERMISSION_ALREADY_GRANTED_DISPLAY_DELAY_MS) - currentPermissionIndex++ - } - } else { - PermissionRequestCard( - permission = perm, - onAllowClick = { - onRequestPermissions(perm.androidPermissions) - }, - onDenyClick = { - permissionResults[perm.id] = PermissionRequestResult.Denied + // Auto-advance after permission result + LaunchedEffect(advanceTrigger) { + if (advanceTrigger > 0) { + kotlinx.coroutines.delay(PERMISSION_EXIT_ANIMATION_DELAY_MS) + if (currentPermissionIndex < permissions.size) { currentPermissionIndex++ - }, - onRequestDismiss = { + } + // If we reached the end, show summary + if (currentPermissionIndex >= permissions.size) { isPermissionFlowActive = false showSummaryDialog = true } - ) + } } } diff --git a/app/src/main/java/com/p2p/meshify/di/AppModule.kt b/app/src/main/java/com/p2p/meshify/di/AppModule.kt index e3352c8b..8f48a934 100644 --- a/app/src/main/java/com/p2p/meshify/di/AppModule.kt +++ b/app/src/main/java/com/p2p/meshify/di/AppModule.kt @@ -36,7 +36,7 @@ object AppModule { return Room.databaseBuilder(context, MeshifyDatabase::class.java, "meshify.db") .addMigrations(migration5to6, MeshifyDatabase.MIGRATION_6_7) - .fallbackToDestructiveMigration(dropAllTables = true) + .fallbackToDestructiveMigration() .build() } diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml index e49b71b2..1ed0b264 100644 --- a/core/common/src/main/res/values/strings.xml +++ b/core/common/src/main/res/values/strings.xml @@ -322,6 +322,13 @@ Message Image Message Status + Message queued to send + Message sending + Message sent + Message delivered + Message received + Message read + Message failed to send Message Reaction @@ -629,4 +636,15 @@ Backup imported successfully Failed to import backup + + + Staged media attachment + Video play icon + Remove attachment + No devices discovered + Open Wi-Fi settings + Image attachment + Error + Retry + QR Code for verification diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt index 6ef97e28..38506882 100644 --- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt +++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/ChatRepositoryImpl.kt @@ -416,7 +416,17 @@ class ChatRepositoryImpl( } val mediaBytes = withContext(Dispatchers.IO) { - mediaFile.readBytes() + // Use streaming read with 8KB buffer instead of file.readBytes() + // to avoid loading the entire file into memory at once. + val outputStream = java.io.ByteArrayOutputStream(mediaFile.length().coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) + java.io.BufferedInputStream(java.io.FileInputStream(mediaFile)).use { inputStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + outputStream.toByteArray() } val myId = settingsRepository.getDeviceId() diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt index 4b535f4a..e17e8a98 100644 --- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt +++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/PendingMessageRepository.kt @@ -144,7 +144,17 @@ class PendingMessageRepository( Logger.e("PendingMessageRepository -> Media file not found for retry: $path") return Result.failure(Exception("Media file not found: $path")) } - file.readBytes() + // Use streaming read with 8KB buffer instead of file.readBytes() + // to avoid loading the entire file into memory at once. + val outputStream = java.io.ByteArrayOutputStream(file.length().coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) + java.io.BufferedInputStream(java.io.FileInputStream(file)).use { inputStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } + } + outputStream.toByteArray() } else { Logger.w("PendingMessageRepository -> No media path for message ${msg.id}") byteArrayOf() diff --git a/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt b/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt index c4524869..2db163c8 100644 --- a/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt +++ b/core/data/src/main/java/com/p2p/meshify/core/data/repository/SettingsRepository.kt @@ -8,10 +8,6 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.p2p.meshify.core.util.Logger -import com.p2p.meshify.domain.model.BubbleStyle -import com.p2p.meshify.domain.model.FontFamilyPreset -import com.p2p.meshify.domain.model.MotionPreset -import com.p2p.meshify.domain.model.ShapeStyle import com.p2p.meshify.domain.model.TransportMode import com.p2p.meshify.domain.repository.ISettingsRepository import com.p2p.meshify.domain.repository.ThemeMode @@ -25,10 +21,6 @@ import java.util.UUID private val Context.dataStore by preferencesDataStore(name = "settings") -/** - * Data layer implementation of Settings Repository. - * Extended for MD3E - Central Source of Truth for all design variables. - */ class SettingsRepository(private val context: Context) : ISettingsRepository { companion object { @@ -39,25 +31,13 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { val KEY_HAPTIC_FEEDBACK = booleanPreferencesKey("haptic_feedback") val KEY_NETWORK_VISIBLE = booleanPreferencesKey("network_visible") val KEY_AVATAR_HASH = stringPreferencesKey("avatar_hash") - - // MD3E Settings Keys - val KEY_SHAPE_STYLE = stringPreferencesKey("shape_style") - val KEY_MOTION_PRESET = stringPreferencesKey("motion_preset") - val KEY_MOTION_SCALE = floatPreferencesKey("motion_scale") - val KEY_FONT_FAMILY = stringPreferencesKey("font_family") - val KEY_CUSTOM_FONT_URI = stringPreferencesKey("custom_font_uri") - val KEY_BUBBLE_STYLE = stringPreferencesKey("bubble_style") - val KEY_VISUAL_DENSITY = floatPreferencesKey("visual_density") val KEY_SEED_COLOR = intPreferencesKey("seed_color") - // BLE Transport Settings Keys val KEY_BLE_ENABLED = booleanPreferencesKey("ble_enabled") val KEY_TRANSPORT_MODE = stringPreferencesKey("transport_mode") - // Onboarding Settings Keys val KEY_ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") - // New Settings Keys val KEY_APP_LANGUAGE = stringPreferencesKey("app_language") val KEY_FONT_SIZE_SCALE = floatPreferencesKey("font_size_scale") val KEY_NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled") @@ -93,58 +73,12 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { preferences[KEY_AVATAR_HASH] } - // MD3E Settings Flows - override val shapeStyle: Flow = context.dataStore.data.map { preferences -> - try { - ShapeStyle.valueOf(preferences[KEY_SHAPE_STYLE] ?: "CIRCLE") - } catch (e: Exception) { - ShapeStyle.CIRCLE - } - } - - override val motionPreset: Flow = context.dataStore.data.map { preferences -> - try { - MotionPreset.valueOf(preferences[KEY_MOTION_PRESET] ?: "STANDARD") - } catch (e: Exception) { - MotionPreset.STANDARD - } - } - - override val motionScale: Flow = context.dataStore.data.map { preferences -> - preferences[KEY_MOTION_SCALE] ?: 1.0f - } - - override val fontFamilyPreset: Flow = context.dataStore.data.map { preferences -> - try { - FontFamilyPreset.valueOf(preferences[KEY_FONT_FAMILY] ?: "ROBOTO") - } catch (e: Exception) { - FontFamilyPreset.ROBOTO - } - } - - override val customFontUri: Flow = context.dataStore.data.map { preferences -> - preferences[KEY_CUSTOM_FONT_URI] - } - - override val bubbleStyle: Flow = context.dataStore.data.map { preferences -> - try { - BubbleStyle.valueOf(preferences[KEY_BUBBLE_STYLE] ?: "ROUNDED") - } catch (e: Exception) { - BubbleStyle.ROUNDED - } - } - - override val visualDensity: Flow = context.dataStore.data.map { preferences -> - preferences[KEY_VISUAL_DENSITY] ?: 1.0f - } - override val seedColor: Flow = context.dataStore.data.map { preferences -> - preferences[KEY_SEED_COLOR] ?: 0xFF006D68.toInt() // Default teal color + preferences[KEY_SEED_COLOR] ?: 0xFF006D68.toInt() } - // BLE Transport Settings Flows override val bleEnabled: Flow = context.dataStore.data.map { preferences -> - preferences[KEY_BLE_ENABLED] ?: false // Opt-in by default (battery saving) + preferences[KEY_BLE_ENABLED] ?: false } override val transportMode: Flow = context.dataStore.data.map { preferences -> @@ -155,14 +89,12 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { } } - // Onboarding Flow override val hasCompletedOnboarding: Flow = context.dataStore.data.map { preferences -> preferences[KEY_ONBOARDING_COMPLETED] ?: false } - // ✅ New Settings Flows override val appLanguage: Flow = context.dataStore.data.map { preferences -> - preferences[KEY_APP_LANGUAGE] ?: "en" // Default English + preferences[KEY_APP_LANGUAGE] ?: "en" } override val fontSizeScale: Flow = context.dataStore.data.map { preferences -> @@ -240,62 +172,12 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { } } - // MD3E Setting Mutators - override suspend fun setShapeStyle(style: ShapeStyle) { - safeEdit { it[KEY_SHAPE_STYLE] = style.name }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to set shape style", e) - } - } - - override suspend fun setMotionPreset(preset: MotionPreset) { - safeEdit { it[KEY_MOTION_PRESET] = preset.name }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to set motion preset", e) - } - } - - override suspend fun setMotionScale(scale: Float) { - safeEdit { it[KEY_MOTION_SCALE] = scale.coerceIn(0.5f, 2.0f) }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to set motion scale", e) - } - } - - override suspend fun setFontFamilyPreset(family: FontFamilyPreset) { - safeEdit { it[KEY_FONT_FAMILY] = family.name }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to set font family", e) - } - } - - override suspend fun setCustomFontUri(uri: String?) { - if (uri != null) { - safeEdit { it[KEY_CUSTOM_FONT_URI] = uri }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to set custom font", e) - } - } else { - safeEdit { it.remove(KEY_CUSTOM_FONT_URI) }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to clear custom font", e) - } - } - } - - override suspend fun setBubbleStyle(style: BubbleStyle) { - safeEdit { it[KEY_BUBBLE_STYLE] = style.name }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to set bubble style", e) - } - } - - override suspend fun setVisualDensity(density: Float) { - safeEdit { it[KEY_VISUAL_DENSITY] = density.coerceIn(0.8f, 1.5f) }.onFailure { e -> - Logger.e("SettingsRepository -> Failed to set visual density", e) - } - } - override suspend fun setSeedColor(color: Int) { safeEdit { it[KEY_SEED_COLOR] = color }.onFailure { e -> Logger.e("SettingsRepository -> Failed to set seed color", e) } } - // BLE Transport Settings Mutators override suspend fun setBleEnabled(enabled: Boolean) { safeEdit { it[KEY_BLE_ENABLED] = enabled }.onFailure { e -> Logger.e("SettingsRepository -> Failed to set BLE enabled", e) @@ -308,7 +190,6 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { } } - // Onboarding Mutators override suspend fun setOnboardingCompleted() { safeEdit { it[KEY_ONBOARDING_COMPLETED] = true }.onFailure { e -> Logger.e("SettingsRepository -> Failed to set onboarding completed", e) @@ -321,7 +202,6 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { } } - // ✅ New Settings Mutators override suspend fun setAppLanguage(language: String) { safeEdit { it[KEY_APP_LANGUAGE] = language }.onFailure { e -> Logger.e("SettingsRepository -> Failed to set app language", e) @@ -354,7 +234,6 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { override suspend fun clearCache() { try { - // Only clear avatar files that are not the current avatar val currentAvatarHash = context.dataStore.data.map { it[KEY_AVATAR_HASH] }.firstOrNull() val avatarsDir = java.io.File(context.filesDir, "avatars") if (avatarsDir.exists() && avatarsDir.isDirectory) { @@ -364,13 +243,10 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { } } } - - // Clear Coil image cache directory only val coilCacheDir = java.io.File(context.cacheDir, "image_manager_disk_cache") if (coilCacheDir.exists()) { coilCacheDir.deleteRecursively() } - Logger.d("SettingsRepository -> Cache cleared successfully") } catch (e: Exception) { Logger.e("SettingsRepository -> Failed to clear cache", e) @@ -380,94 +256,32 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { override suspend fun exportBackup(): Result { return try { - // ✅ CODE-03: Added error handling for blocking .first() call - // Prevents app freeze if DataStore is corrupted or locked val prefs = try { context.dataStore.data.first() } catch (e: Exception) { - Logger.e("SettingsRepository -> Failed to read DataStore", e) return Result.failure(Exception("Failed to read preferences: ${e.message}", e)) } - + val backupData = mapOf( - // Core settings "display_name" to prefs[KEY_DISPLAY_NAME], "theme_mode" to prefs[KEY_THEME_MODE], "dynamic_color" to prefs[KEY_DYNAMIC_COLOR], "haptic_feedback" to prefs[KEY_HAPTIC_FEEDBACK], "network_visible" to prefs[KEY_NETWORK_VISIBLE], "avatar_hash" to prefs[KEY_AVATAR_HASH], - // MD3E settings - "shape_style" to prefs[KEY_SHAPE_STYLE], - "motion_preset" to prefs[KEY_MOTION_PRESET], - "motion_scale" to prefs[KEY_MOTION_SCALE]?.toString(), - "font_family" to prefs[KEY_FONT_FAMILY], - "custom_font_uri" to prefs[KEY_CUSTOM_FONT_URI], - "bubble_style" to prefs[KEY_BUBBLE_STYLE], - "visual_density" to prefs[KEY_VISUAL_DENSITY]?.toString(), "seed_color" to prefs[KEY_SEED_COLOR]?.toString(), - // App settings "app_language" to prefs[KEY_APP_LANGUAGE], "font_size_scale" to prefs[KEY_FONT_SIZE_SCALE]?.toString(), "notifications_enabled" to prefs[KEY_NOTIFICATIONS_ENABLED], "notification_sound" to prefs[KEY_NOTIFICATION_SOUND], "notification_vibrate" to prefs[KEY_NOTIFICATION_VIBRATE], - // BLE settings "ble_enabled" to prefs[KEY_BLE_ENABLED], "transport_mode" to prefs[KEY_TRANSPORT_MODE], - // Metadata "export_timestamp" to System.currentTimeMillis().toString() - ) - // filterValues removes all nulls, so mapValues safely receives non-null values - .filterValues { it != null }.mapValues { it.value.toString() } + ).filterValues { it != null }.mapValues { it.value.toString() } val json = Json.encodeToString(backupData) - Logger.d("SettingsRepository -> Backup exported successfully") Result.success(json) } catch (e: Exception) { - Logger.e("SettingsRepository -> Failed to export backup", e) - Result.failure(e) - } - } - - override suspend fun importBackup(backupJson: String): Result { - return try { - val backupData = Json.decodeFromString>(backupJson) - val editResult = safeEdit { prefs -> - // Core settings - backupData["display_name"]?.let { prefs[KEY_DISPLAY_NAME] = it } - backupData["theme_mode"]?.let { prefs[KEY_THEME_MODE] = it } - backupData["dynamic_color"]?.let { prefs[KEY_DYNAMIC_COLOR] = it.toBoolean() } - backupData["haptic_feedback"]?.let { prefs[KEY_HAPTIC_FEEDBACK] = it.toBoolean() } - backupData["network_visible"]?.let { prefs[KEY_NETWORK_VISIBLE] = it.toBoolean() } - backupData["avatar_hash"]?.let { prefs[KEY_AVATAR_HASH] = it } - // MD3E settings - backupData["shape_style"]?.let { prefs[KEY_SHAPE_STYLE] = it } - backupData["motion_preset"]?.let { prefs[KEY_MOTION_PRESET] = it } - backupData["motion_scale"]?.let { prefs[KEY_MOTION_SCALE] = it.toFloat() } - backupData["font_family"]?.let { prefs[KEY_FONT_FAMILY] = it } - backupData["custom_font_uri"]?.let { prefs[KEY_CUSTOM_FONT_URI] = it } - backupData["bubble_style"]?.let { prefs[KEY_BUBBLE_STYLE] = it } - backupData["visual_density"]?.let { prefs[KEY_VISUAL_DENSITY] = it.toFloat() } - backupData["seed_color"]?.let { prefs[KEY_SEED_COLOR] = it.toInt() } - // App settings - backupData["app_language"]?.let { prefs[KEY_APP_LANGUAGE] = it } - backupData["font_size_scale"]?.let { prefs[KEY_FONT_SIZE_SCALE] = it.toFloat() } - backupData["notifications_enabled"]?.let { prefs[KEY_NOTIFICATIONS_ENABLED] = it.toBoolean() } - backupData["notification_sound"]?.let { prefs[KEY_NOTIFICATION_SOUND] = it.toBoolean() } - backupData["notification_vibrate"]?.let { prefs[KEY_NOTIFICATION_VIBRATE] = it.toBoolean() } - // BLE settings - backupData["ble_enabled"]?.let { prefs[KEY_BLE_ENABLED] = it.toBoolean() } - backupData["transport_mode"]?.let { prefs[KEY_TRANSPORT_MODE] = it } - } - - if (editResult.isFailure) { - return Result.failure(editResult.exceptionOrNull() ?: Exception("Failed to import backup")) - } - - Logger.d("SettingsRepository -> Backup imported successfully") - Result.success(Unit) - } catch (e: Exception) { - Logger.e("SettingsRepository -> Failed to import backup", e) Result.failure(e) } } @@ -481,10 +295,6 @@ class SettingsRepository(private val context: Context) : ISettingsRepository { } } - /** - * Safely edits preferences with error handling. - * BUG FIX #5: Now returns Result to allow callers to handle errors properly. - */ private suspend fun safeEdit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit): Result { return try { context.dataStore.edit { block(it) } diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index e14567f8..49207c65 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -10,7 +10,6 @@ kotlin { dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.androidx.graphics.shapes) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/model/SignalStrength.kt b/core/domain/src/main/java/com/p2p/meshify/domain/model/SignalStrength.kt index 3a36309e..455a487a 100644 --- a/core/domain/src/main/java/com/p2p/meshify/domain/model/SignalStrength.kt +++ b/core/domain/src/main/java/com/p2p/meshify/domain/model/SignalStrength.kt @@ -1,108 +1,19 @@ package com.p2p.meshify.domain.model -import androidx.graphics.shapes.CornerRounding -import androidx.graphics.shapes.RoundedPolygon - -/** - * MD3E Signal Strength Enum. - * Represents the RSSI-based signal quality for peer devices in Discovery screen. - * - * Used by SignalMorphAvatar to determine: - * - Shape morphing speed (stronger = faster) - * - Shape selection (stronger = more complex shapes) - * - Color treatment - */ enum class SignalStrength { - /** - * Excellent signal (RSSI > -50 dBm) - * - Shape: Sunny ↔ Breezy (complex, vibrant) - * - Speed: Very fast (500ms) - * - Color: Primary/Strong teal - */ STRONG, - - /** - * Good signal (RSSI -50 to -70 dBm) - * - Shape: Breezy ↔ Circle (moderate complexity) - * - Speed: Medium (900ms) - * - Color: Secondary/Muted teal - */ MEDIUM, - - /** - * Weak signal (RSSI < -70 dBm) - * - Shape: Circle ↔ Blob (simple, calm) - * - Speed: Slow (1500ms) - * - Color: Gray/desaturated - */ WEAK, - - /** - * Offline/Disconnected - * - Shape: Circle (static, no morphing) - * - Speed: No animation - * - Color: Gray overlay - */ OFFLINE; companion object { - /** - * Convert RSSI (dBm) to SignalStrength. - * RSSI values are typically negative, closer to 0 = stronger signal. - * - * @param rssi The signal strength in dBm (e.g., -42, -65, -80) - * @return Corresponding SignalStrength enum value - */ fun fromRssi(rssi: Int): SignalStrength { return when { - rssi > -50 -> STRONG // Excellent signal - rssi in -70..-50 -> MEDIUM // Good signal - rssi < -70 -> WEAK // Weak signal + rssi > -50 -> STRONG + rssi in -70..-50 -> MEDIUM + rssi < -70 -> WEAK else -> OFFLINE } } } } - -/** - * Get morph duration based on signal strength. - * Stronger signals = faster morphing (more "vitality"). - * - * @return Duration in milliseconds for one morph cycle - */ -fun SignalStrength.getMorphDuration(): Int { - return when (this) { - SignalStrength.STRONG -> 500 // Very fast - SignalStrength.MEDIUM -> 900 // Medium - SignalStrength.WEAK -> 1500 // Slow - SignalStrength.OFFLINE -> 0 // No animation - } -} - -/** - * Get shape pair for morphing based on signal strength. - * Returns two shapes to morph between. - * - * Note: This uses simple shapes from androidx.graphics.shapes. - * For complex MD3E shapes (Sunny, Breezy, etc.), use the UI layer implementation. - */ -fun SignalStrength.getShapePair(): List { - return when (this) { - SignalStrength.STRONG -> listOf( - RoundedPolygon(numVertices = 10, radius = 1f), - RoundedPolygon(numVertices = 9, radius = 1f) - ) - SignalStrength.MEDIUM -> listOf( - RoundedPolygon(numVertices = 9, radius = 1f), - RoundedPolygon(numVertices = 6, radius = 1f) - ) - SignalStrength.WEAK -> listOf( - RoundedPolygon(numVertices = 6, radius = 1f), - RoundedPolygon(numVertices = 4, radius = 1f) - ) - SignalStrength.OFFLINE -> { - val circle = RoundedPolygon(numVertices = 16, radius = 1f) - listOf(circle, circle) - } - } -} diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt b/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt index 0cf6778b..4bc92d22 100644 --- a/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt +++ b/core/domain/src/main/java/com/p2p/meshify/domain/model/ThemeConfig.kt @@ -1,46 +1,9 @@ package com.p2p.meshify.domain.model -/** - * MD3E Shape Styles - Central source of truth for shape morphing. - */ -enum class ShapeStyle { - SUNNY, // 10-pointed star - BREEZY, // 9-pointed star - PENTAGON, // 5-sided polygon - BLOB, // Organic blob shape - BURST, // 8-pointed explosion - CLOVER, // 4-leaf clover - CIRCLE // Perfect circle -} +enum class ShapeStyle { SQUARE } -/** - * MD3E Motion Presets - Spring physics configurations. - */ -enum class MotionPreset { - GENTLE, // Low stiffness, high damping - STANDARD, // Balanced MD3E default - SNAPPY, // High stiffness, low damping - BOUNCY // Very bouncy, playful -} +enum class MotionPreset { STANDARD } -/** - * MD3E Font Families - Google Fonts integration. - */ -enum class FontFamilyPreset { - ROBOTO, // Default system font - POPPINS, // Modern geometric - LORA, // Elegant serif - MONTSERRAT, // Urban contemporary - PLAYFAIR, // Display serif - INTER // Clean UI font -} +enum class FontFamilyPreset { ROBOTO } -/** - * MD3E Bubble Styles - Chat bubble shapes. - */ -enum class BubbleStyle { - ROUNDED, // Classic rounded - TAILED, // With speech tail - SQUARCLES, // Square-circles - ORGANIC // Free-form organic -} +enum class BubbleStyle { ROUNDED } diff --git a/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt b/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt index 78baf9d0..0e996c1a 100644 --- a/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt +++ b/core/domain/src/main/java/com/p2p/meshify/domain/repository/ISettingsRepository.kt @@ -1,21 +1,10 @@ package com.p2p.meshify.domain.repository -import com.p2p.meshify.domain.model.BubbleStyle -import com.p2p.meshify.domain.model.FontFamilyPreset -import com.p2p.meshify.domain.model.MotionPreset -import com.p2p.meshify.domain.model.ShapeStyle import com.p2p.meshify.domain.model.TransportMode import kotlinx.coroutines.flow.Flow -/** - * Validated Theme Modes. - */ enum class ThemeMode { LIGHT, DARK, SYSTEM } -/** - * Domain interface for user preferences and identity. - * Extended for MD3E - Central Source of Truth for all design variables. - */ interface ISettingsRepository { val displayName: Flow val themeMode: Flow @@ -23,35 +12,13 @@ interface ISettingsRepository { val hapticFeedbackEnabled: Flow val isNetworkVisible: Flow val avatarHash: Flow - - // MD3E Settings - Shape Morphing - val shapeStyle: Flow - - // MD3E Settings - Motion System - val motionPreset: Flow - val motionScale: Flow - - // MD3E Settings - Typography - val fontFamilyPreset: Flow - val customFontUri: Flow - - // MD3E Settings - Chat Bubbles - val bubbleStyle: Flow - - // MD3E Settings - Visual Density - val visualDensity: Flow - - // MD3E Settings - Seed Color (for static theming when dynamic color is off) val seedColor: Flow - // BLE Transport Settings val bleEnabled: Flow val transportMode: Flow - // Onboarding val hasCompletedOnboarding: Flow - // New Settings - Language, Font Size, Notifications, Storage, Backup val appLanguage: Flow val fontSizeScale: Flow val notificationsEnabled: Flow @@ -65,26 +32,14 @@ interface ISettingsRepository { suspend fun setHapticFeedback(enabled: Boolean) suspend fun setNetworkVisibility(visible: Boolean) suspend fun updateAvatarHash(hash: String?) - - // MD3E Setting Mutators - suspend fun setShapeStyle(style: ShapeStyle) - suspend fun setMotionPreset(preset: MotionPreset) - suspend fun setMotionScale(scale: Float) - suspend fun setFontFamilyPreset(family: FontFamilyPreset) - suspend fun setCustomFontUri(uri: String?) - suspend fun setBubbleStyle(style: BubbleStyle) - suspend fun setVisualDensity(density: Float) suspend fun setSeedColor(color: Int) - // BLE Transport Settings Mutators suspend fun setBleEnabled(enabled: Boolean) suspend fun setTransportMode(mode: TransportMode) - // Onboarding Mutators suspend fun setOnboardingCompleted() suspend fun resetOnboardingCompleted() - // New Settings Mutators suspend fun setAppLanguage(language: String) suspend fun setFontSizeScale(scale: Float) suspend fun setNotificationsEnabled(enabled: Boolean) @@ -92,7 +47,5 @@ interface ISettingsRepository { suspend fun setNotificationVibrate(enabled: Boolean) suspend fun clearCache() suspend fun exportBackup(): Result - suspend fun importBackup(backupJson: String): Result - fun getAppVersion(): String } diff --git a/core/domain/src/test/java/com/p2p/meshify/domain/model/SignalStrengthTest.kt b/core/domain/src/test/java/com/p2p/meshify/domain/model/SignalStrengthTest.kt index 169cd293..d8720d0d 100644 --- a/core/domain/src/test/java/com/p2p/meshify/domain/model/SignalStrengthTest.kt +++ b/core/domain/src/test/java/com/p2p/meshify/domain/model/SignalStrengthTest.kt @@ -32,41 +32,6 @@ class SignalStrengthTest { assertEquals(SignalStrength.WEAK, SignalStrength.fromRssi(-90)) } - @Test - fun `getMorphDuration returns correct values for each strength`() { - assertEquals(500, SignalStrength.STRONG.getMorphDuration()) - assertEquals(900, SignalStrength.MEDIUM.getMorphDuration()) - assertEquals(1500, SignalStrength.WEAK.getMorphDuration()) - assertEquals(0, SignalStrength.OFFLINE.getMorphDuration()) - } - - @Test - fun `getShapePair returns correct shapes for STRONG`() { - val shapes = SignalStrength.STRONG.getShapePair() - - assertEquals(2, shapes.size) - // STRONG should return two different complex shapes - assertNotEquals(shapes[0], shapes[1]) - } - - @Test - fun `getShapePair returns correct shapes for MEDIUM`() { - val shapes = SignalStrength.MEDIUM.getShapePair() - - assertEquals(2, shapes.size) - // MEDIUM should return two different shapes - assertNotEquals(shapes[0], shapes[1]) - } - - @Test - fun `getShapePair returns circle for OFFLINE`() { - val shapes = SignalStrength.OFFLINE.getShapePair() - - assertEquals(2, shapes.size) - // Both should be circles for OFFLINE - check if they're equal (circles are identical) - assertEquals(shapes[0], shapes[1]) - } - @Test fun `SignalStrength enum values are correct`() { assertEquals(4, SignalStrength.values().size) diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/ProgressFileReader.kt b/core/network/src/main/java/com/p2p/meshify/core/network/ProgressFileReader.kt index 8e36008d..7123f90e 100644 --- a/core/network/src/main/java/com/p2p/meshify/core/network/ProgressFileReader.kt +++ b/core/network/src/main/java/com/p2p/meshify/core/network/ProgressFileReader.kt @@ -38,7 +38,10 @@ class ProgressFileReader( } val buffer = ByteArray(BUFFER_SIZE) - val outputStream = java.io.ByteArrayOutputStream(fileSize.toInt()) + // Cap initial capacity at Int.MAX_VALUE to prevent silent Long-to-Int + // overflow for files larger than 2 GB. + val initialCapacity = if (fileSize <= Int.MAX_VALUE) fileSize.toInt() else Int.MAX_VALUE + val outputStream = java.io.ByteArrayOutputStream(initialCapacity) var uploaded: Long = 0 var lastEmittedProgress = -1 diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/TransportManager.kt b/core/network/src/main/java/com/p2p/meshify/core/network/TransportManager.kt index 504fea4f..97e374c4 100644 --- a/core/network/src/main/java/com/p2p/meshify/core/network/TransportManager.kt +++ b/core/network/src/main/java/com/p2p/meshify/core/network/TransportManager.kt @@ -118,8 +118,17 @@ class TransportManager( return when (transportMode) { TransportMode.MULTI_PATH -> { - // Return ALL available transports for multi-path sending - if (capableTransports.isNotEmpty()) capableTransports else availableTransports + // Return transports where the peer is actually online + val transportsWithPeerOnline = capableTransports.filter { transport -> + transport.onlinePeers.value.contains(peerId) + } + if (transportsWithPeerOnline.isNotEmpty()) { + transportsWithPeerOnline + } else if (capableTransports.isNotEmpty()) { + capableTransports + } else { + availableTransports + } } TransportMode.LAN_ONLY -> { listOfNotNull(getTransport("lan")) diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/ble/BlePayloadSerializer.kt b/core/network/src/main/java/com/p2p/meshify/core/network/ble/BlePayloadSerializer.kt index fee39f99..5674c9b0 100644 --- a/core/network/src/main/java/com/p2p/meshify/core/network/ble/BlePayloadSerializer.kt +++ b/core/network/src/main/java/com/p2p/meshify/core/network/ble/BlePayloadSerializer.kt @@ -107,16 +107,17 @@ object BlePayloadSerializer { ReassemblyState(totalChunks, totalSize) } - // Update sliding window timeout on each chunk arrival - state.lastUpdateTime = System.currentTimeMillis() - - // Check for timeout + // Check for timeout BEFORE updating lastUpdateTime — otherwise + // now - now = 0 and the timeout NEVER triggers. if (System.currentTimeMillis() - state.lastUpdateTime > AppConfig.BLE_REASSEMBLY_TIMEOUT_MS) { Logger.w("Reassembly timeout for key: $reassemblyKey", tag = TAG) reassemblyBuffers.remove(reassemblyKey) return null } + // Update sliding window timeout on each chunk arrival + state.lastUpdateTime = System.currentTimeMillis() + state.chunks[chunkIndex] = chunkData // Check if all chunks received diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt b/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt index 3817d93a..002787ff 100644 --- a/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt +++ b/core/network/src/main/java/com/p2p/meshify/core/network/lan/ConnectionPool.kt @@ -231,12 +231,11 @@ class ConnectionPool { /** * Clears all connections (for shutdown). + * Uses drainPermits() to safely reset the semaphore regardless of + * how many permits were already released by removeConnection(). */ fun clearAll() { - // FIX: Use toList() to create a snapshot before modifying to avoid any potential - // issues with iterator.remove() during concurrent access val entries = activeConnections.toList() - val activeCount = entries.size entries.forEach { (key, pooledSocket) -> try { pooledSocket.socket.close() @@ -244,14 +243,15 @@ class ConnectionPool { Logger.e("ConnectionPool -> Failed to close socket: $key", e) } } - // FIX: Drain and release all permits to prevent permit leak on shutdown activeConnections.clear() - // Release permits for all closed connections - repeat(activeCount) { - poolSemaphore.release() - } + // drainPermits() acquires all available permits, returning the count. + // We then release back to MAX_POOL_SIZE to reset cleanly. + // This is safe even if removeConnection() already released some permits, + // because we drain whatever remains and restore the full amount. + poolSemaphore.drainPermits() + poolSemaphore.release(MAX_POOL_SIZE) connectionLocks.clear() knownPeers.clear() - Logger.d("ConnectionPool -> Cleared all connections, released $activeCount permits") + Logger.d("ConnectionPool -> Cleared all connections, semaphore reset to $MAX_POOL_SIZE") } } diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt b/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt index 4da104d8..0d6e2905 100644 --- a/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt +++ b/core/network/src/main/java/com/p2p/meshify/core/network/lan/KeepAliveManager.kt @@ -32,6 +32,8 @@ class KeepAliveManager( /** * Sends keep-alive ping to all active connections. * Should be called periodically (every KEEP_ALIVE_INTERVAL_MS). + * After sending PING, attempts to read PONG response from the same socket + * to verify bidirectional connectivity. * * @return Number of pings sent successfully */ @@ -48,7 +50,7 @@ class KeepAliveManager( if (idleTime < halfIdleTimeout) { try { - // Send PING message + // Send PING message directly on the pooled socket val pingPayload = Payload( senderId = "system", type = Payload.PayloadType.SYSTEM_CONTROL, @@ -63,6 +65,34 @@ class KeepAliveManager( outputStream.write(bytes) outputStream.flush() } + + // After sending PING, attempt to read PONG response from the + // same socket's input stream to verify bidirectional connectivity. + // This detects half-open connections where write succeeds but + // the remote end has gone away. + val socket = pooledSocket.socket + val originalTimeout = socket.soTimeout + try { + socket.soTimeout = 100 // brief check — avoids long block + val inputStream = java.io.DataInputStream(socket.getInputStream()) + // Only try to read if data is available (non-blocking check) + if (inputStream.available() > 0) { + socket.soTimeout = (PING_TIMEOUT_MS / 2).toInt() + val responseLength = inputStream.readInt() + if (responseLength > 0 && responseLength < 1024) { + val responseBytes = ByteArray(responseLength) + inputStream.readFully(responseBytes) + val responseData = String(responseBytes, Charsets.UTF_8) + if (responseData.contains("PONG")) { + Logger.d("KeepAliveManager -> Received PONG from $peerId") + } + } + } + } catch (e: java.net.SocketTimeoutException) { + // Timeout is acceptable — no data pending, socket is alive + } finally { + socket.soTimeout = originalTimeout + } } connectionPool.updateLastUsed(peerId) @@ -71,7 +101,7 @@ class KeepAliveManager( Logger.d("KeepAliveManager -> Sent ping to $peerId") } catch (e: Exception) { // Connection is dead, remove it - Logger.d("KeepAliveManager -> Keep-alive failed for $peerId, removing connection") + Logger.d("KeepAliveManager -> Keep-alive failed for $peerId, removing connection: ${e.message}") connectionPool.removeConnection(peerId, closeSocket = true) deadCount++ } diff --git a/core/network/src/main/java/com/p2p/meshify/core/network/lan/LanTransportImpl.kt b/core/network/src/main/java/com/p2p/meshify/core/network/lan/LanTransportImpl.kt index d577f95a..06785765 100644 --- a/core/network/src/main/java/com/p2p/meshify/core/network/lan/LanTransportImpl.kt +++ b/core/network/src/main/java/com/p2p/meshify/core/network/lan/LanTransportImpl.kt @@ -218,6 +218,17 @@ class LanTransportImpl( when (command) { "TYPING_ON" -> _typingPeers.update { it + senderId } "TYPING_OFF" -> _typingPeers.update { it - senderId } + "PING" -> { + // Respond with PONG so the remote KeepAliveManager knows the + // connection is still alive + scope.launch { + sendPayload(senderId, Payload( + senderId = settingsRepository.getDeviceId(), + type = Payload.PayloadType.SYSTEM_CONTROL, + data = "PONG".toByteArray() + )) + } + } } } @@ -370,19 +381,21 @@ class LanTransportImpl( } /** - * Estimate RSSI based on network round-trip time (RTT). - * Lower latency = closer proximity = stronger signal. - * RTT thresholds: <10ms = -40dBm (excellent), >200ms = -85dBm (poor) + * Estimate RSSI based on existing connection latency. + * Only uses already-established connections — does NOT open new TCP + * connections for estimation (wasteful and slow). + * + * RTT thresholds: <5ms = -40dBm (excellent), >100ms = -85dBm (poor) * * @param peerAddress The peer's IP address to measure latency to */ private fun estimateRssiFromLatency(peerAddress: String): Int { return try { - // Try to use existing socket from connection pool + // Only use existing socket from connection pool — never open a new + // TCP connection just for RSSI estimation. val existingSocket = socketManager.hasValidConnection(peerAddress) if (existingSocket) { - // If we have an existing socket, use it for latency estimation val socket = socketManager.getConnection(peerAddress) if (socket != null && socket.isConnected && !socket.isClosed) { val startTime = System.currentTimeMillis() @@ -409,32 +422,9 @@ class LanTransportImpl( -85 } } else { - // No existing socket, try a quick connection test - val socket = java.net.Socket() - try { - socket.soTimeout = 1000 - val startTime = System.currentTimeMillis() - socket.connect(java.net.InetSocketAddress(peerAddress, AppConfig.DEFAULT_PORT), 1000) - val rtt = System.currentTimeMillis() - startTime - - // Convert RTT to estimated RSSI - when { - rtt < 10 -> -40 // Excellent: very close, low latency - rtt < 50 -> -55 // Good: same subnet, fast response - rtt < 100 -> -65 // Moderate: some network delay - rtt < 200 -> -75 // Poor: significant latency - else -> -85 // Very poor: high latency, edge of range - } - } finally { - // Always close the socket to prevent resource leak - try { - if (!socket.isClosed) { - socket.close() - } - } catch (e: Exception) { - Logger.w("LanTransport -> Failed to close RSSI test socket: ${e.message}") - } - } + // No existing connection — return a conservative default + // instead of opening a new socket just for estimation. + -85 } } catch (e: Exception) { // Cannot estimate — return very poor signal diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 3ebc9a1b..3eed60ab 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -48,7 +48,6 @@ android { dependencies { implementation(project(":core:common")) implementation(project(":core:domain")) - implementation(project(":core:data")) // Compose BOM implementation(platform(libs.androidx.compose.bom)) diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt index aa022ae0..84735ef1 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/AlbumMediaGrid.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade -import com.p2p.meshify.core.data.local.entity.MessageAttachmentEntity +import com.p2p.meshify.core.ui.model.AttachmentUiModel import com.p2p.meshify.domain.model.MessageType import java.io.File @@ -48,7 +48,7 @@ import java.io.File */ @Composable fun AlbumMediaGrid( - attachments: List, + attachments: List, caption: String?, onImageClick: (String) -> Unit, modifier: Modifier = Modifier @@ -103,7 +103,7 @@ fun AlbumMediaGrid( */ @Composable private fun AlbumMediaItem( - attachment: MessageAttachmentEntity, + attachment: AttachmentUiModel, onClick: (String) -> Unit, modifier: Modifier = Modifier ) { diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt index 2dc2b11d..e1ac409e 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/ForwardMessageDialog.kt @@ -36,10 +36,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.local.entity.ChatEntity -import com.p2p.meshify.core.data.local.entity.MessageEntity import com.p2p.meshify.core.ui.hooks.HapticPattern import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics +import com.p2p.meshify.core.ui.model.AttachmentUiModel +import com.p2p.meshify.core.ui.model.ChatUiModel +import com.p2p.meshify.core.ui.model.MessageUiModel import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import com.p2p.meshify.domain.model.MessageType import com.p2p.meshify.domain.model.PeerDevice @@ -51,10 +52,10 @@ import java.util.* * State holder for Forward Message Dialog. */ data class ForwardDialogState( - val messages: List = emptyList(), - val recentChats: List = emptyList(), + val messages: List = emptyList(), + val recentChats: List = emptyList(), val discoveredDevices: List = emptyList(), - val allChats: List = emptyList(), + val allChats: List = emptyList(), val onlinePeerIds: Set = emptySet(), // ✅ FIX: Track online peers val selectedPeerIds: Set = emptySet(), val searchQuery: String = "", @@ -65,7 +66,7 @@ data class ForwardDialogState( val canForward: Boolean = selectedPeerIds.isNotEmpty() val selectedCount: Int = selectedPeerIds.size - val filteredRecentChats: List + val filteredRecentChats: List get() = if (searchQuery.isBlank()) { recentChats.take(5) } else { @@ -79,7 +80,7 @@ data class ForwardDialogState( discoveredDevices.filter { it.name.contains(searchQuery, ignoreCase = true) } } - val filteredAllChats: List + val filteredAllChats: List get() = if (searchQuery.isBlank()) { allChats.filterNot { it.peerId in recentChats.map { chat -> chat.peerId }.toSet() } } else { @@ -438,7 +439,7 @@ fun ForwardMessageDialog( */ @Composable private fun ForwardPreviewCard( - messages: List + messages: List ) { Surface( modifier = Modifier.fillMaxWidth(), @@ -624,9 +625,8 @@ private fun ForwardPeerItem( ) // Avatar - MorphingAvatar( - initials = name.take(1), - isOnline = isOnline, + MeshifyAvatar( + initials = name.take(2), size = 44.dp ) diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt index 76b895a4..d1c6c3c8 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKit.kt @@ -1,15 +1,9 @@ package com.p2p.meshify.core.ui.components -import android.graphics.Bitmap -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.GenericShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.* @@ -17,119 +11,43 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Color +import com.p2p.meshify.core.ui.theme.StatusOnline import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.graphics.shapes.Morph -import androidx.graphics.shapes.RoundedPolygon -import androidx.graphics.shapes.toPath +import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import com.p2p.meshify.core.ui.R -import com.p2p.meshify.core.ui.hooks.HapticPattern -import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics -import com.p2p.meshify.core.ui.theme.LocalMeshifyMotion -import com.p2p.meshify.core.ui.theme.LocalMeshifyThemeConfig -import com.p2p.meshify.core.ui.theme.MD3EShapes import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import com.p2p.meshify.core.util.FileUtils -import com.p2p.meshify.domain.model.Handshake import java.io.File -/** - * Robustly transforms a RoundedPolygon Path to fit and center within a given Size. - * Uses the mathematically correct order: Translate to target center -> Scale -> Translate from source center. - */ -fun android.graphics.Path.toCenteredComposePath(size: Size, scaleFactor: Float = 0.9f): Path { - val path = this.asComposePath() - val bounds = path.getBounds() - val matrix = Matrix() - - // Calculate scale to fit while maintaining aspect ratio - val scale = minOf(size.width / bounds.width, size.height / bounds.height) * scaleFactor - - // Target center - val targetCenterX = size.width / 2f - val targetCenterY = size.height / 2f - - // Source center (of the polygon's own bounds) - val sourceCenterX = bounds.left + bounds.width / 2f - val sourceCenterY = bounds.top + bounds.height / 2f - - // Transformation sequence (Applied in reverse order in post-concat Matrix): - // 1. Move source center to origin (0,0) - // 2. Scale - // 3. Move origin to target center - matrix.translate(targetCenterX, targetCenterY) - matrix.scale(scale, scale) - matrix.translate(-sourceCenterX, -sourceCenterY) - - path.transform(matrix) - return path -} - -/** - * Optimized Shape for MD3E Morphing. - */ -class MorphingPolygonShape( - private val morph: Morph, - private val progress: Float, - private val scaleFactor: Float = 0.85f -) : Shape { - override fun createOutline(size: Size, layoutDirection: androidx.compose.ui.unit.LayoutDirection, density: androidx.compose.ui.unit.Density): Outline { - // Generate the path at the current morph progress and center it - val path = morph.toPath(progress).toCenteredComposePath(size, scaleFactor) - return Outline.Generic(path) - } -} - @Composable -fun MorphingAvatar( +fun MeshifyAvatar( initials: String, avatarHash: String? = null, - isOnline: Boolean = false, size: Dp = 56.dp, modifier: Modifier = Modifier ) { val context = LocalContext.current - val config = LocalMeshifyThemeConfig.current - val cleanInitials = initials.filter { it.isLetterOrDigit() }.take(1).uppercase() + val cleanInitials = initials.filter { it.isLetterOrDigit() }.take(2).uppercase() val avatarFile = remember(avatarHash) { avatarHash?.let { hash -> FileUtils.getFilePath(context, hash, "avatars")?.let { File(it) } } } - val polygon = remember(config.shapeStyle) { MD3EShapes.getShape(config.shapeStyle) } - val avatarShape = remember(polygon) { - GenericShape { targetSize, _ -> - addPath(polygon.toPath().toCenteredComposePath(targetSize, scaleFactor = 0.95f)) - } - } - - Box( - modifier = modifier.size(size), - contentAlignment = Alignment.Center - ) { - if (isOnline) { - Box(Modifier.fillMaxSize().graphicsLayer { - clip = true - shape = avatarShape - alpha = 0.15f - }.background(MaterialTheme.colorScheme.primary)) - } + Box(modifier = modifier.size(size)) { Surface( - modifier = Modifier.fillMaxSize(if (isOnline) 0.82f else 1f), - shape = avatarShape, + modifier = Modifier.fillMaxSize(), + shape = MeshifyDesignSystem.Shapes.Avatar, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 2.dp ) { @@ -142,17 +60,35 @@ fun MorphingAvatar( ) } else { Box(contentAlignment = Alignment.Center) { - Text(text = cleanInitials, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.primary) + Text( + text = cleanInitials, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.primary + ) } } } + } +} + +@Composable +fun MeshifyAvatarWithOnline( + initials: String, + avatarHash: String? = null, + isOnline: Boolean = false, + size: Dp = 56.dp, + modifier: Modifier = Modifier +) { + Box(modifier = modifier.size(size)) { + MeshifyAvatar(initials = initials, avatarHash = avatarHash, size = size) if (isOnline) { Box( Modifier .size(size / 4.5f) .align(Alignment.BottomEnd) - .background(Color(0xFF4CAF50), CircleShape) - .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape) + .background(StatusOnline) + .border(2.dp, MaterialTheme.colorScheme.surface) ) } } @@ -160,31 +96,17 @@ fun MorphingAvatar( @Composable fun MeshifyCard(modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, content: @Composable ColumnScope.() -> Unit) { - Surface(modifier = modifier.fillMaxWidth().then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), shape = MeshifyDesignSystem.Shapes.CardLarge, color = containerColor, tonalElevation = MeshifyDesignSystem.Elevation.Level2) { + Surface(modifier = modifier.fillMaxWidth().then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), shape = MeshifyDesignSystem.Shapes.Card, color = containerColor, tonalElevation = MeshifyDesignSystem.Elevation.Level2) { Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), content = content) } } @Composable fun MeshifyListItem(headline: String, supporting: String? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.98f else 1f, - animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), - label = "item_scale" - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { - scaleX = scale - scaleY = scale - }, + modifier = Modifier.fillMaxWidth(), color = Color.Transparent, - onClick = onClick, - interactionSource = interactionSource + onClick = onClick ) { Row(modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md, vertical = MeshifyDesignSystem.Spacing.Sm), verticalAlignment = Alignment.CenterVertically) { if (leadingContent != null) { Box(Modifier.size(56.dp), contentAlignment = Alignment.Center) { leadingContent() }; Spacer(Modifier.width(MeshifyDesignSystem.Spacing.Md)) } @@ -197,71 +119,6 @@ fun MeshifyListItem(headline: String, supporting: String? = null, leadingContent } } -@Composable -fun RadarPulseMorph(isSearching: Boolean, size: Dp = 44.dp, modifier: Modifier = Modifier) { - if (!isSearching) { - Surface(modifier = modifier.size(size), shape = CircleShape, color = MaterialTheme.colorScheme.primary.copy(0.1f)) { Icon(Icons.Default.Add, null, modifier = Modifier.padding(8.dp), tint = MaterialTheme.colorScheme.primary) } - return - } - val infiniteTransition = rememberInfiniteTransition(label = "Radar") - val pulses = listOf(infiniteTransition.animateFloat(0f, 1f, infiniteRepeatable(tween(2000, easing = LinearEasing), RepeatMode.Restart), "P1"), infiniteTransition.animateFloat(0f, 1f, infiniteRepeatable(tween(2000, 600, easing = LinearEasing), RepeatMode.Restart), "P2")) - Box(modifier = modifier.size(size * 2.5f), contentAlignment = Alignment.Center) { - pulses.forEach { p -> Box(Modifier.size(size * 2.5f * p.value).graphicsLayer { alpha = 1f - p.value }.border(2.dp, MaterialTheme.colorScheme.primary.copy(0.5f), CircleShape)) } - Surface(modifier = Modifier.size(size), shape = CircleShape, color = MaterialTheme.colorScheme.primary, tonalElevation = 6.dp) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Add, null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onPrimary) } } - } -} - -/** - * Animated Morphing FAB (Material 3 Expressive). - * Fixed matrix centering and naming to resolve visual glitches and build failures. - */ -@Composable -fun AnimatedMorphingFAB(onClick: () -> Unit, modifier: Modifier = Modifier) { - val config = LocalMeshifyThemeConfig.current - val haptics = LocalPremiumHaptics.current - - val targetPolygon = remember(config.shapeStyle) { MD3EShapes.getShape(config.shapeStyle) } - var previousPolygon by remember { mutableStateOf(targetPolygon) } - var currentPolygon by remember { mutableStateOf(targetPolygon) } - - LaunchedEffect(config.shapeStyle) { - previousPolygon = currentPolygon - currentPolygon = targetPolygon - } - - val morphProgress = remember { Animatable(0f) } - LaunchedEffect(currentPolygon) { - morphProgress.snapTo(0f) - morphProgress.animateTo(1f, spring(dampingRatio = 0.8f, stiffness = 300f)) - } - - val morph = remember(previousPolygon, currentPolygon) { - Morph(previousPolygon, currentPolygon) - } - - // Using a custom Shape class for better performance and clean matrix logic - val animatedShape = MorphingPolygonShape(morph, morphProgress.value, scaleFactor = 0.82f) - - FloatingActionButton( - onClick = { - haptics.perform(HapticPattern.Pop) - onClick() - }, - modifier = modifier - .size(64.dp) - .navigationBarsPadding(), - shape = animatedShape, - containerColor = MaterialTheme.colorScheme.primaryContainer, - elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 6.dp) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = "New Chat", - modifier = Modifier.size(32.dp) - ) - } -} - @Composable fun MeshifySectionHeader(title: String) { Text(text = title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Black, modifier = Modifier.padding(start = 16.dp, top = 32.dp, bottom = 12.dp), letterSpacing = 1.sp) @@ -269,19 +126,5 @@ fun MeshifySectionHeader(title: String) { @Composable fun MeshifyPill(text: String, containerColor: Color = MaterialTheme.colorScheme.secondaryContainer) { - Surface(color = containerColor, shape = CircleShape) { Text(text = text, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), fontWeight = FontWeight.Bold) } -} - -@Composable -fun PremiumNoiseTexture(modifier: Modifier = Modifier, alpha: Float = 0.03f) { - Box(modifier = modifier.fillMaxSize().background(Color.Black.copy(alpha = alpha))) -} - -@Composable -fun ExpressivePulseHeader(modifier: Modifier = Modifier, size: Dp = 120.dp, content: @Composable BoxScope.() -> Unit) { - Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { - Surface(modifier = Modifier.size(size), shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() } - } - } + Surface(color = containerColor, shape = MeshifyDesignSystem.Shapes.Pill) { Text(text = text, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), fontWeight = FontWeight.Bold) } } diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKitDialogs.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKitDialogs.kt index cba589a9..8a1e0428 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKitDialogs.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/MeshifyKitDialogs.kt @@ -35,8 +35,6 @@ import com.p2p.meshify.core.ui.theme.ColorPresetPurple import com.p2p.meshify.core.ui.theme.ColorPresetRed import com.p2p.meshify.core.ui.theme.ColorPresetTeal import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem -import com.p2p.meshify.domain.model.MotionPreset -import com.p2p.meshify.domain.model.ShapeStyle import com.p2p.meshify.domain.repository.ThemeMode import java.io.File diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt index f94a7645..69dfb313 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/PhysicsSwipeToDelete.kt @@ -20,9 +20,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.p2p.meshify.core.common.R import com.p2p.meshify.core.ui.hooks.HapticPattern import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics import kotlinx.coroutines.launch @@ -115,7 +117,7 @@ fun PhysicsSwipeToDelete( containerColor = MaterialTheme.colorScheme.surfaceVariant, alpha = unlockProgress ) { - Icon(Icons.Rounded.Close, null, Modifier.size(22.dp)) + Icon(Icons.Rounded.Close, stringResource(R.string.content_desc_close), Modifier.size(22.dp)) } PhysicsSwipeActionButton( onClick = { @@ -127,7 +129,7 @@ fun PhysicsSwipeToDelete( ) { Icon( Icons.Rounded.Delete, - null, + stringResource(R.string.content_desc_delete), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp) ) @@ -139,7 +141,7 @@ fun PhysicsSwipeToDelete( .fillMaxWidth() .offset { IntOffset(offsetX.value.roundToInt(), 0) } .clip(shape) - .background(MaterialTheme.colorScheme.surface) + .background(MaterialTheme.colorScheme.surfaceContainerLow) .pointerInput(Unit) { detectHorizontalDragGestures( onDragStart = { isDragging = true }, diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/QrCodeDisplay.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/QrCodeDisplay.kt index 58746da1..40c9f180 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/QrCodeDisplay.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/QrCodeDisplay.kt @@ -9,7 +9,9 @@ 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.p2p.meshify.core.common.R import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem +import androidx.compose.ui.res.stringResource /** * Displays a QR code for Out-Of-Band identity verification. @@ -55,13 +57,13 @@ fun QrCodeDisplay( Surface( modifier = Modifier.size(240.dp), color = MaterialTheme.colorScheme.surface, - shape = MeshifyDesignSystem.Shapes.CardMedium, + shape = MeshifyDesignSystem.Shapes.Card, tonalElevation = MeshifyDesignSystem.Elevation.Level2 ) { Box(contentAlignment = Alignment.Center) { Icon( imageVector = Icons.Default.QrCode, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_qr_code), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp) ) diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/StagedMediaRow.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/StagedMediaRow.kt index d79eed3d..e9983432 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/components/StagedMediaRow.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/components/StagedMediaRow.kt @@ -28,11 +28,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import com.p2p.meshify.core.common.R import com.p2p.meshify.core.ui.model.StagedAttachment import com.p2p.meshify.domain.model.MessageType import java.io.File @@ -91,7 +93,7 @@ private fun StagedMediaThumbnail( .data(attachment.uri) .crossfade(true) .build(), - contentDescription = "Staged media", + contentDescription = stringResource(R.string.content_desc_staged_media), modifier = Modifier .size(80.dp) .clip(RoundedCornerShape(12.dp)), @@ -109,7 +111,7 @@ private fun StagedMediaThumbnail( ) { Icon( imageVector = Icons.Filled.PlayArrow, - contentDescription = "Video", + contentDescription = stringResource(R.string.content_desc_video_icon), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(32.dp) ) @@ -129,7 +131,7 @@ private fun StagedMediaThumbnail( ) { Icon( imageVector = Icons.Filled.Close, - contentDescription = "Remove", + contentDescription = stringResource(R.string.content_desc_remove_attachment), tint = MaterialTheme.colorScheme.onError, modifier = Modifier.size(14.dp) ) diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/model/AttachmentUiModel.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/model/AttachmentUiModel.kt new file mode 100644 index 00000000..d240084b --- /dev/null +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/model/AttachmentUiModel.kt @@ -0,0 +1,14 @@ +package com.p2p.meshify.core.ui.model + +import com.p2p.meshify.domain.model.MessageType + +/** + * UI model for a message attachment, used in UI components like AlbumMediaGrid. + * This replaces direct dependency on [com.p2p.meshify.core.data.local.entity.MessageAttachmentEntity] + * to keep core:ui free of data-layer dependencies. + */ +data class AttachmentUiModel( + val id: String, + val type: MessageType, + val filePath: String +) diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/model/ChatUiModel.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/model/ChatUiModel.kt new file mode 100644 index 00000000..551a6490 --- /dev/null +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/model/ChatUiModel.kt @@ -0,0 +1,14 @@ +package com.p2p.meshify.core.ui.model + +/** + * UI model for a chat conversation, used in UI components. + * This replaces direct dependency on [com.p2p.meshify.core.data.local.entity.ChatEntity] + * to keep core:ui free of data-layer dependencies. + */ +data class ChatUiModel( + val peerId: String, + val peerName: String, + val lastMessage: String?, + val lastTimestamp: Long = 0L, + val unreadCount: Int = 0 +) diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/model/MessageUiModel.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/model/MessageUiModel.kt new file mode 100644 index 00000000..dc897946 --- /dev/null +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/model/MessageUiModel.kt @@ -0,0 +1,15 @@ +package com.p2p.meshify.core.ui.model + +import com.p2p.meshify.domain.model.MessageType + +/** + * UI model for a message, used in UI components like ForwardMessageDialog. + * This replaces direct dependency on [com.p2p.meshify.core.data.local.entity.MessageEntity] + * to keep core:ui free of data-layer dependencies. + */ +data class MessageUiModel( + val id: String, + val text: String?, + val type: MessageType, + val timestamp: Long = 0L +) diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/navigation/MeshifyNavigation.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/navigation/MeshifyNavigation.kt index dcb4eae5..c25940d8 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/navigation/MeshifyNavigation.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/navigation/MeshifyNavigation.kt @@ -1,6 +1,14 @@ package com.p2p.meshify.core.ui.navigation +import androidx.compose.foundation.background +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.graphics.Color import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -17,13 +25,13 @@ import androidx.navigation.toRoute fun MeshifyNavHost( navController: NavHostController, startDestination: Screen = Screen.Home, - onOnboardingRoute: @Composable () -> Unit = {}, - onHomeRoute: @Composable () -> Unit = {}, - onDiscoveryRoute: @Composable () -> Unit = {}, - onChatRoute: @Composable (peerId: String, peerName: String?) -> Unit = { _, _ -> }, - onSettingsRoute: @Composable () -> Unit = {}, - onDeveloperRoute: @Composable () -> Unit = {}, - onRealDeviceTestingRoute: @Composable () -> Unit = {} + onOnboardingRoute: @Composable () -> Unit = { MissingComposable() }, + onHomeRoute: @Composable () -> Unit = { MissingComposable() }, + onDiscoveryRoute: @Composable () -> Unit = { MissingComposable() }, + onChatRoute: @Composable (peerId: String, peerName: String?) -> Unit = { _, _ -> MissingComposable() }, + onSettingsRoute: @Composable () -> Unit = { MissingComposable() }, + onDeveloperRoute: @Composable () -> Unit = { MissingComposable() }, + onRealDeviceTestingRoute: @Composable () -> Unit = { MissingComposable() } ) { NavHost( navController = navController, @@ -59,3 +67,10 @@ fun MeshifyNavHost( } } } + +@Composable +private fun MissingComposable() { + Box(modifier = Modifier.fillMaxSize().background(Color.Red.copy(alpha = 0.2f)), contentAlignment = Alignment.Center) { + Text("Missing route composable", color = Color.Red) + } +} diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Font.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Font.kt deleted file mode 100644 index 4f50f2c4..00000000 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Font.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.p2p.meshify.core.ui.theme - -import androidx.compose.ui.text.font.FontFamily -import com.p2p.meshify.domain.model.FontFamilyPreset - -/** - * MD3E Font Families. - * Using system fonts for simplicity. Google Fonts can be added later if needed. - */ -object MD3EFontFamilies { - - /** - * Roboto - Default system font. - * Clean, modern, and highly readable. - */ - val Roboto = FontFamily.SansSerif - - /** - * Poppins - Modern geometric sans-serif. - * Perfect for headings and display text. - */ - val Poppins = FontFamily.SansSerif - - /** - * Lora - Elegant serif font. - * Ideal for long-form reading. - */ - val Lora = FontFamily.Serif - - /** - * Montserrat - Urban sans-serif. - * Great for UI elements and buttons. - */ - val Montserrat = FontFamily.SansSerif - - /** - * Playfair Display - Classic serif. - * Perfect for titles and headers. - */ - val PlayfairDisplay = FontFamily.Serif - - /** - * Inter - Clean, readable sans-serif. - * Excellent for body text. - */ - val Inter = FontFamily.SansSerif - - /** - * Get FontFamily by preset enum. - */ - fun getFontFamily(preset: FontFamilyPreset): FontFamily { - return when (preset) { - FontFamilyPreset.POPPINS -> Poppins - FontFamilyPreset.LORA -> Lora - FontFamilyPreset.MONTSERRAT -> Montserrat - FontFamilyPreset.PLAYFAIR -> PlayfairDisplay - FontFamilyPreset.INTER -> Inter - FontFamilyPreset.ROBOTO -> Roboto - } - } -} diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MD3ETheme.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MD3ETheme.kt deleted file mode 100644 index 5ced1293..00000000 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MD3ETheme.kt +++ /dev/null @@ -1,207 +0,0 @@ -package com.p2p.meshify.core.ui.theme - -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.spring -import androidx.graphics.shapes.CornerRounding -import androidx.graphics.shapes.RoundedPolygon -import androidx.graphics.shapes.star -import androidx.graphics.shapes.circle -import android.graphics.Matrix - -/** - * MD3E Spring Physics Presets. - * Based on Material 3 Expressive Motion System. - */ -object MotionSpecs { - - /** - * Gentle motion - Low stiffness, high damping. - * For subtle, calm animations. - */ - val Gentle = spring( - dampingRatio = 0.9f, - stiffness = 300f - ) - - /** - * Standard MD3E motion - Balanced spring physics. - * Default for most expressive components. - */ - val Standard = spring( - dampingRatio = 0.8f, - stiffness = 600f - ) - - /** - * Snappy motion - High stiffness, low damping. - * For quick, responsive interactions. - */ - val Snappy = spring( - dampingRatio = 0.6f, - stiffness = 1000f - ) - - /** - * Bouncy motion - Very playful and elastic. - * Uses dampingRatio = 0.4f for fun, rubber-band effect per LastChat design audit. - */ - val Bouncy = spring( - dampingRatio = 0.4f, - stiffness = 800f - ) - - /** - * Get spring spec based on motion preset and scale. - * Scale factor adjusts stiffness: 0.5x = half stiffness, 2.0x = double stiffness. - */ - fun getSpring(preset: com.p2p.meshify.domain.model.MotionPreset, scale: Float = 1.0f): SpringSpec { - // Clamp scale to safe bounds to prevent extreme behavior - val clampedScale = scale.coerceIn(0.5f, 2.0f) - - return when (preset) { - com.p2p.meshify.domain.model.MotionPreset.GENTLE -> { - // Scale stiffness inversely: lower scale = gentler (lower stiffness) - val scaledStiffness = 300f * clampedScale - spring(dampingRatio = 0.9f, stiffness = scaledStiffness) - } - com.p2p.meshify.domain.model.MotionPreset.STANDARD -> { - val scaledStiffness = 600f * clampedScale - spring(dampingRatio = 0.8f, stiffness = scaledStiffness) - } - com.p2p.meshify.domain.model.MotionPreset.SNAPPY -> { - val scaledStiffness = 1000f * clampedScale - spring(dampingRatio = 0.6f, stiffness = scaledStiffness) - } - com.p2p.meshify.domain.model.MotionPreset.BOUNCY -> { - val scaledStiffness = 800f * clampedScale - spring(dampingRatio = 0.4f, stiffness = scaledStiffness) - } - } - } -} - -/** - * MD3E Shape Definitions. - * All shapes are normalized to (0,0) to (1,1) coordinate system. - */ -object MD3EShapes { - - /** - * Sunny - 10-pointed star with moderate rounding. - */ - val Sunny: RoundedPolygon by lazy { - RoundedPolygon.star( - numVerticesPerRadius = 10, - innerRadius = 0.65f, - rounding = CornerRounding(0.2f) - ).normalize() - } - - /** - * Breezy - 9-pointed star with soft edges. - */ - val Breezy: RoundedPolygon by lazy { - RoundedPolygon.star( - numVerticesPerRadius = 9, - innerRadius = 0.85f, - rounding = CornerRounding(0.3f) - ).normalize() - } - - /** - * Pentagon - Simple 5-sided polygon. - */ - val Pentagon: RoundedPolygon by lazy { - RoundedPolygon( - numVertices = 5, - rounding = CornerRounding(0.2f) - ).normalize() - } - - /** - * Blob - Organic blob shape (2-vertex star with high rounding). - */ - val Blob: RoundedPolygon by lazy { - RoundedPolygon.star( - numVerticesPerRadius = 2, - innerRadius = 0.3f, - rounding = CornerRounding(0.9f) - ).normalize() - } - - /** - * Burst - 8-pointed explosion shape. - */ - val Burst: RoundedPolygon by lazy { - RoundedPolygon.star( - numVerticesPerRadius = 8, - innerRadius = 0.8f, - rounding = CornerRounding(0.15f) - ).normalize() - } - - /** - * Clover - 4-leaf clover shape. - */ - val Clover: RoundedPolygon by lazy { - RoundedPolygon.star( - numVerticesPerRadius = 4, - innerRadius = 0.7f, - rounding = CornerRounding(0.4f) - ).normalize() - } - - /** - * Circle - Perfect circle (12 vertices). - */ - val Circle: RoundedPolygon by lazy { - RoundedPolygon.circle( - numVertices = 12 - ).normalize() - } - - /** - * Get shape by ShapeStyle enum. - */ - fun getShape(style: com.p2p.meshify.domain.model.ShapeStyle): RoundedPolygon { - return when (style) { - com.p2p.meshify.domain.model.ShapeStyle.SUNNY -> Sunny - com.p2p.meshify.domain.model.ShapeStyle.BREEZY -> Breezy - com.p2p.meshify.domain.model.ShapeStyle.PENTAGON -> Pentagon - com.p2p.meshify.domain.model.ShapeStyle.BLOB -> Blob - com.p2p.meshify.domain.model.ShapeStyle.BURST -> Burst - com.p2p.meshify.domain.model.ShapeStyle.CLOVER -> Clover - com.p2p.meshify.domain.model.ShapeStyle.CIRCLE -> Circle - } - } - - /** - * All available shapes for morphing. - */ - val AllShapes: List by lazy { - listOf(Sunny, Breezy, Pentagon, Blob, Burst, Clover, Circle) - } - - /** - * Normalize a RoundedPolygon to (0,0) to (1,1) bounds. - * This ensures consistent morphing between shapes. - * Note: In androidx.graphics.shapes 0.4.0+, shapes are already normalized. - */ - private fun RoundedPolygon.normalize(): RoundedPolygon { - // The library already normalizes shapes internally - // Just return self to avoid API compatibility issues - return this - } -} - -/** - * MD3E Duration Tokens. - * Standard animation durations for consistent timing. - */ -object MotionDurations { - const val Instant = 100 // Immediate feedback - const val Short = 200 // Small micro-interactions - const val Medium = 300 // Standard component transitions - const val Long = 500 // Large scale changes - const val ExtraLong = 800 // Complex morphing animations -} diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt index 414ccee4..493d9bf1 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/MeshifyDesignSystem.kt @@ -1,17 +1,10 @@ package com.p2p.meshify.core.ui.theme -import androidx.compose.animation.core.spring import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -/** - * Meshify Unified Design System. - * Single Source of Truth for Spacing, Shapes, and Elevation. - */ object MeshifyDesignSystem { object Spacing { @@ -25,17 +18,17 @@ object MeshifyDesignSystem { } object Shapes { - val CardLarge = RoundedCornerShape(28.dp) - val CardMedium = RoundedCornerShape(20.dp) - val CardSmall = RoundedCornerShape(16.dp) - val BubbleMe = RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) - val BubblePeer = RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp) - val Button = RoundedCornerShape(20.dp) - val Input = RoundedCornerShape(24.dp) - val Pill = RoundedCornerShape(50) + val Card = RoundedCornerShape(12.dp) + val CardLarge = RoundedCornerShape(16.dp) + val CardSmall = RoundedCornerShape(8.dp) + val Button = RoundedCornerShape(10.dp) + val Input = RoundedCornerShape(8.dp) + val Pill = RoundedCornerShape(8.dp) + val Avatar = RoundedCornerShape(8.dp) + val IconContainer = RoundedCornerShape(12.dp) + val Dialog = RoundedCornerShape(16.dp) } - // ✅ FIX: Icon Sizes - use this instead of hardcoded values object IconSizes { val Small = 18.dp val Medium = 22.dp @@ -44,7 +37,6 @@ object MeshifyDesignSystem { val XXL = 40.dp } - // ✅ FIX: Avatar Sizes - use this instead of hardcoded values object AvatarSizes { val Small = 40.dp val Medium = 48.dp @@ -53,16 +45,8 @@ object MeshifyDesignSystem { val XXL = 120.dp } - // ✅ FIX: Dialog Shapes - use this instead of hardcoded values - object DialogShapes { - val Default = RoundedCornerShape(28.dp) - val Small = RoundedCornerShape(16.dp) - val Medium = RoundedCornerShape(20.dp) - } - - // ✅ FIX: Seed Color Presets - use this instead of hardcoded values object SeedColorPresets { - val Teal = Color(0xFF008080) + val Teal = Color(0xFF006D68) val Blue = Color(0xFF0000FF) val Purple = Color(0xFF800080) val Pink = Color(0xFFFFC0CB) @@ -82,11 +66,4 @@ object MeshifyDesignSystem { val Level4 = 6.dp val Level5 = 8.dp } - - object Motion { - fun expressiveSpring() = spring( - dampingRatio = 0.75f, - stiffness = 350f - ) - } } diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt index 654ae262..eada6d9a 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Theme.kt @@ -2,7 +2,6 @@ package com.p2p.meshify.core.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -10,147 +9,19 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -/** - * MD3E Motion Configuration. - * Central source for spring physics and motion presets. - */ -@Immutable -data class MeshifyMotion( - val springSpec: androidx.compose.animation.core.SpringSpec = MotionSpecs.Standard, - val scale: Float = 1.0f -) - -val LocalMeshifyMotion = staticCompositionLocalOf { MeshifyMotion() } - -/** - * MD3E Theme Configuration. - * Central Source of Truth for all design variables. - * Includes seedColor for static theming and customFontUri for external fonts. - */ @Immutable data class MeshifyThemeConfig( - val shapeStyle: com.p2p.meshify.domain.model.ShapeStyle = com.p2p.meshify.domain.model.ShapeStyle.CIRCLE, - val motionPreset: com.p2p.meshify.domain.model.MotionPreset = com.p2p.meshify.domain.model.MotionPreset.STANDARD, - val motionScale: Float = 1.0f, - val fontFamily: FontFamily = MD3EFontFamilies.Roboto, - val customFontUri: String? = null, - val bubbleStyle: com.p2p.meshify.domain.model.BubbleStyle = com.p2p.meshify.domain.model.BubbleStyle.ROUNDED, - val visualDensity: Float = 1.0f, - val seedColor: Color = Color(0xFF006D68) // Default teal + val seedColor: Color = Color(0xFF006D68) ) val LocalMeshifyThemeConfig = staticCompositionLocalOf { MeshifyThemeConfig() } -/** - * Shared Dimensions and Shapes for Consistency. - */ -object MeshifyThemeProperties { - val ChatBubbleRadius = 24.dp - val ChatBubbleGroupedRadius = 4.dp - val CardRadius = 28.dp - val AvatarRadius = 16.dp -} - -object ChatBubbleShapes { - val Ungrouped = RoundedCornerShape(MeshifyThemeProperties.ChatBubbleRadius) - - val MeGroupedTop = RoundedCornerShape( - topStart = MeshifyThemeProperties.ChatBubbleRadius, - topEnd = MeshifyThemeProperties.ChatBubbleGroupedRadius, - bottomEnd = MeshifyThemeProperties.ChatBubbleRadius, - bottomStart = MeshifyThemeProperties.ChatBubbleRadius - ) - - val MeGroupedMiddle = RoundedCornerShape( - topStart = MeshifyThemeProperties.ChatBubbleRadius, - topEnd = MeshifyThemeProperties.ChatBubbleGroupedRadius, - bottomEnd = MeshifyThemeProperties.ChatBubbleGroupedRadius, - bottomStart = MeshifyThemeProperties.ChatBubbleRadius - ) - - val MeGroupedBottom = RoundedCornerShape( - topStart = MeshifyThemeProperties.ChatBubbleRadius, - topEnd = MeshifyThemeProperties.ChatBubbleRadius, - bottomEnd = MeshifyThemeProperties.ChatBubbleRadius, - bottomStart = MeshifyThemeProperties.ChatBubbleRadius - ) - - val PeerGroupedTop = RoundedCornerShape( - topStart = MeshifyThemeProperties.ChatBubbleGroupedRadius, - topEnd = MeshifyThemeProperties.ChatBubbleRadius, - bottomEnd = MeshifyThemeProperties.ChatBubbleRadius, - bottomStart = MeshifyThemeProperties.ChatBubbleRadius - ) - - val PeerGroupedMiddle = RoundedCornerShape( - topStart = MeshifyThemeProperties.ChatBubbleGroupedRadius, - topEnd = MeshifyThemeProperties.ChatBubbleRadius, - bottomEnd = MeshifyThemeProperties.ChatBubbleRadius, - bottomStart = MeshifyThemeProperties.ChatBubbleGroupedRadius - ) -} - -/** - * Get chat bubble shape based on selected BubbleStyle from settings. - */ -fun getBubbleShape( - bubbleStyle: com.p2p.meshify.domain.model.BubbleStyle, - isFromMe: Boolean, - isGroupedWithPrevious: Boolean, - isGroupedWithNext: Boolean -): RoundedCornerShape { - val radius = MeshifyThemeProperties.ChatBubbleRadius - val smallRadius = MeshifyThemeProperties.ChatBubbleGroupedRadius - - return when (bubbleStyle) { - com.p2p.meshify.domain.model.BubbleStyle.ROUNDED -> { - // Classic rounded bubbles - when { - isFromMe -> { - when { - isGroupedWithPrevious && isGroupedWithNext -> ChatBubbleShapes.MeGroupedMiddle - isGroupedWithPrevious -> ChatBubbleShapes.MeGroupedTop - isGroupedWithNext -> ChatBubbleShapes.MeGroupedBottom - else -> ChatBubbleShapes.Ungrouped - } - } - else -> { - when { - isGroupedWithPrevious && isGroupedWithNext -> ChatBubbleShapes.PeerGroupedMiddle - isGroupedWithPrevious -> ChatBubbleShapes.PeerGroupedTop - else -> ChatBubbleShapes.Ungrouped - } - } - } - } - com.p2p.meshify.domain.model.BubbleStyle.TAILED -> { - // More pronounced tail effect with asymmetric corners - RoundedCornerShape( - topStart = if (isFromMe) radius else smallRadius, - topEnd = if (isFromMe) smallRadius else radius, - bottomEnd = radius, - bottomStart = radius - ) - } - com.p2p.meshify.domain.model.BubbleStyle.SQUARCLES -> { - // Square-circles: minimal rounding - RoundedCornerShape(8.dp) - } - com.p2p.meshify.domain.model.BubbleStyle.ORGANIC -> { - // Extra rounded, almost pill-like - RoundedCornerShape(32.dp) - } - } -} - private val DarkColorScheme = darkColorScheme( primary = PrimaryDark, - onPrimary = androidx.compose.ui.graphics.Color(0xFF003737), - primaryContainer = androidx.compose.ui.graphics.Color(0xFF004F4F), - onPrimaryContainer = androidx.compose.ui.graphics.Color(0xFF6FF6F6), + onPrimary = Color(0xFF003737), + primaryContainer = Color(0xFF004F4F), + onPrimaryContainer = Color(0xFF6FF6F6), secondary = SecondaryDark, tertiary = TertiaryDark, background = BackgroundDark, @@ -167,24 +38,13 @@ private val LightColorScheme = lightColorScheme( tertiary = MeshifyTertiary, error = MeshifyError, onError = MeshifyOnError, - surfaceContainerHigh = androidx.compose.ui.graphics.Color(0xFFF7F2FA) + surfaceContainerHigh = Color(0xFFF7F2FA) ) -/** - * MD3E Theme - Central Source of Truth. - * Integrates with Settings Repository for dynamic theming. - * Supports seedColor for static theming when dynamic color is disabled. - */ @Composable fun MeshifyTheme( themeMode: String = "SYSTEM", dynamicColor: Boolean = true, - motionPreset: com.p2p.meshify.domain.model.MotionPreset = com.p2p.meshify.domain.model.MotionPreset.STANDARD, - motionScale: Float = 1.0f, - fontFamily: FontFamily = MD3EFontFamilies.Roboto, - shapeStyle: com.p2p.meshify.domain.model.ShapeStyle = com.p2p.meshify.domain.model.ShapeStyle.CIRCLE, - bubbleStyle: com.p2p.meshify.domain.model.BubbleStyle = com.p2p.meshify.domain.model.BubbleStyle.ROUNDED, - visualDensity: Float = 1.0f, seedColor: Color = Color(0xFF006D68), content: @Composable () -> Unit ) { @@ -203,26 +63,11 @@ fun MeshifyTheme( else -> LightColorScheme } - val motion = MeshifyMotion( - springSpec = MotionSpecs.getSpring(motionPreset, motionScale), - scale = motionScale - ) - CompositionLocalProvider( - LocalMeshifyMotion provides motion, - LocalMeshifyThemeConfig provides MeshifyThemeConfig( - motionPreset = motionPreset, - motionScale = motionScale, - fontFamily = fontFamily, - shapeStyle = shapeStyle, - bubbleStyle = bubbleStyle, - visualDensity = visualDensity, - seedColor = seedColor - ) + LocalMeshifyThemeConfig provides MeshifyThemeConfig(seedColor = seedColor) ) { MaterialTheme( colorScheme = colorScheme, - typography = getTypography(fontFamily), content = content ) } diff --git a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt index 737fd0ff..0eeda644 100644 --- a/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt +++ b/core/ui/src/main/java/com/p2p/meshify/core/ui/theme/Type.kt @@ -6,10 +6,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -/** - * Full MD3 Expressive Typography Scale. - * Uses default font family - will be overridden by Theme with Google Fonts. - */ val Typography = Typography( displayLarge = TextStyle( fontFamily = FontFamily.SansSerif, @@ -111,111 +107,3 @@ val Typography = Typography( letterSpacing = 0.5.sp ) ) - -/** - * Get MD3E Typography with custom font family. - * This allows dynamic font switching based on user settings. - */ -fun getTypography(fontFamily: FontFamily): Typography { - return Typography( - displayLarge = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Bold, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = (-0.25).sp - ), - displayMedium = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Bold, - fontSize = 45.sp, - lineHeight = 52.sp - ), - displaySmall = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Bold, - fontSize = 36.sp, - lineHeight = 44.sp - ), - headlineLarge = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.ExtraBold, - fontSize = 32.sp, - lineHeight = 40.sp - ), - headlineMedium = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Bold, - fontSize = 28.sp, - lineHeight = 36.sp - ), - headlineSmall = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - lineHeight = 32.sp - ), - titleLarge = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp - ), - titleMedium = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.15.sp - ), - titleSmall = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - bodyLarge = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), - bodyMedium = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - bodySmall = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp - ), - labelLarge = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - labelMedium = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ), - labelSmall = TextStyle( - fontFamily = fontFamily, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - ) -} diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index 072860c1..bc93dcfc 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -11,7 +11,6 @@ android { defaultConfig { minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } @@ -69,22 +68,5 @@ dependencies { implementation(libs.coil3.compose) implementation(libs.coil3.network) - // Testing - testImplementation(libs.junit) - testImplementation(libs.mockk) - testImplementation(libs.turbine) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.androidx.core.testing) - - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - androidTestImplementation(libs.mockkAndroid) - androidTestImplementation(libs.turbine) - androidTestImplementation(libs.kotlinx.coroutines.test) - androidTestImplementation(libs.androidx.core.testing) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt index 526a0ca9..7436c257 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CircularProgressIndicator @@ -37,7 +36,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -68,7 +66,6 @@ import com.p2p.meshify.core.ui.components.FullImageViewer import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import com.p2p.meshify.core.ui.hooks.HapticPattern import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics -import com.p2p.meshify.core.ui.theme.LocalMeshifyThemeConfig import com.p2p.meshify.domain.model.DeleteType import com.p2p.meshify.domain.model.MessageType import com.p2p.meshify.feature.chat.components.BackConfirmationDialog @@ -125,14 +122,13 @@ fun ChatScreen( ) { val context = LocalContext.current val haptics = LocalPremiumHaptics.current - val uiState by viewModel.uiState.collectAsState() - val selectedMessages by viewModel.selectedMessages.collectAsState() - val forwardDialogState by viewModel.forwardDialogState.collectAsState() - val isSearching by viewModel.isSearching.collectAsState() - val searchQuery by viewModel.searchQuery.collectAsState() - val searchResults by viewModel.searchResults.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val selectedMessages by viewModel.selectedMessages.collectAsStateWithLifecycle() + val forwardDialogState by viewModel.forwardDialogState.collectAsStateWithLifecycle() + val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() val listState = rememberLazyListState() - val themeConfig = LocalMeshifyThemeConfig.current val clipboard = LocalClipboardManager.current var menuMessage by remember { mutableStateOf(null) } var selectedFullImage by remember { mutableStateOf(null) } @@ -245,16 +241,6 @@ fun ChatScreen( } } - // Lazy loading: load more when user scrolls to top - LaunchedEffect(listState) { - snapshotFlow { listState.firstVisibleItemIndex } - .collect { firstVisibleIndex -> - if (firstVisibleIndex < 5 && uiState.hasMoreMessages && !uiState.isLoadingMore) { - viewModel.loadMoreMessages() - } - } - } - // BackHandler: exit search mode first BackHandler(enabled = isSearching) { viewModel.stopSearch() @@ -385,7 +371,7 @@ fun ChatScreen( modifier = Modifier .size(48.dp) .semantics { contentDescription = loadingDesc }, - shape = CircleShape, + shape = MeshifyDesignSystem.Shapes.IconContainer, color = MaterialTheme.colorScheme.surfaceContainerHighest ) { CircularProgressIndicator( @@ -409,12 +395,10 @@ fun ChatScreen( MessageList( messages = uiState.messages, isLoading = uiState.isLoading, - isLoadingMore = uiState.isLoadingMore, selectedMessages = selectedMessages, uploadProgressMap = uploadProgressMap, transportUsed = uiState.transportUsed, peerName = peerName, - bubbleStyle = themeConfig.bubbleStyle, listState = listState, getAttachmentsForGroupId = viewModel::getAttachmentsForMessage, onLongClick = { message -> diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt index b2d860cb..d8bdc87a 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/ChatViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.local.entity.ChatEntity import com.p2p.meshify.core.data.local.entity.MessageAttachmentEntity import com.p2p.meshify.core.data.local.entity.MessageEntity import com.p2p.meshify.core.data.repository.ChatRepositoryImpl @@ -17,6 +16,7 @@ import com.p2p.meshify.domain.model.DeleteType import com.p2p.meshify.domain.model.MessageType import com.p2p.meshify.domain.model.TransportType import com.p2p.meshify.domain.security.model.SecurityEvent +import com.p2p.meshify.core.ui.model.MessageUiModel import com.p2p.meshify.core.ui.model.StagedAttachment import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -29,7 +29,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds import java.io.File -import java.util.UUID import javax.inject.Inject /** Debounce interval for search input to avoid excessive DB queries */ @@ -37,17 +36,17 @@ private const val SEARCH_DEBOUNCE_MS = 300L private const val ATTACHMENT_CACHE_MAX_SIZE = 200 +/** Maximum number of transport type entries to keep in state map */ +private const val TRANSPORT_HISTORY_MAX_SIZE = 100 + data class ChatUiState( val isLoading: Boolean = true, val messages: List = emptyList(), val isOnline: Boolean = false, - val isPeerTyping: Boolean = false, val inputText: String = "", - val draftText: String = "", // P2-11: Persisted draft text survives config changes + val draftText: String = "", val replyTo: MessageEntity? = null, val stagedAttachments: List = emptyList(), - val hasMoreMessages: Boolean = false, - val isLoadingMore: Boolean = false, val isSending: Boolean = false, val sendError: String? = null, val uploadError: String? = null, @@ -77,7 +76,6 @@ class ChatViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val stageMutex = Mutex() - private val paginationMutex = Mutex() // Forward dialog state private val _forwardDialogState = MutableStateFlow(ForwardDialogState()) @@ -109,43 +107,18 @@ class ChatViewModel @Inject constructor( private var searchCollectionJob: kotlinx.coroutines.Job? = null - // Pagination state - using ArrayDeque for O(1) prepend operations - private var currentPage = 0 - private val pageSize = 50 - private var isAllMessagesLoaded = false - private val allMessages = ArrayDeque(initialCapacity = 100) - - // ✅ PERF-01: Maximum messages to keep in memory (reduced from 500 to 200) - // Reduces memory usage by 5-8MB in long conversations - // 200 messages = ~4MB vs 500 messages = ~10MB - companion object { - private const val MAX_MESSAGES_IN_MEMORY = 200 // Reduced from 500 for better memory efficiency - } - // ✅ Double tap protection - prevent sending same message twice private var lastSendTime = 0L private val sendDebounceMs = 500L // 500ms debounce init { - // Load initial page of messages - loadMoreMessages() - - // ✅ FIX: Collect messages flow with distinctUntilChanged to reduce recompositions - // This ensures real-time updates when messages are received from the network viewModelScope.launch { repository.getMessages(peerId) - .distinctUntilChanged() // ✅ PF03: Prevent excessive recompositions + .distinctUntilChanged() .collect { messages -> Logger.d("ChatViewModel -> Messages updated: ${messages.size} messages for peer $peerId") - - // ✅ FIX: Only update UI state, don't manipulate allMessages here - // allMessages is only for pagination (loadMoreMessages) _uiState.update { - it.copy( - isLoading = false, - messages = messages, - hasMoreMessages = !isAllMessagesLoaded - ) + it.copy(isLoading = false, messages = messages) } } } @@ -173,61 +146,6 @@ class ChatViewModel @Inject constructor( } } - /** - * Loads more messages for pagination. - * Called initially and when user scrolls to top. - */ - fun loadMoreMessages() { - viewModelScope.launch { - // Use tryLock to avoid waiting if already loading - if (!paginationMutex.tryLock()) return@launch - - try { - if (isAllMessagesLoaded || _uiState.value.isLoadingMore) return@launch - - _uiState.update { it.copy(isLoadingMore = true) } - - try { - // ✅ PF04: FIX blocking .first() by using take(1).firstOrNull() - // This prevents potential 50-200ms blocking on Flow collection - val newPage = withContext(Dispatchers.IO) { - repository.getMessagesPaged(peerId, pageSize, currentPage * pageSize) - .take(1) - .firstOrNull() - ?: emptyList() - } - - if (newPage.isEmpty()) { - isAllMessagesLoaded = true - } else { - // Prepend new messages efficiently using ArrayDeque - allMessages.addAll(0, newPage) - - // Remove oldest messages if exceeding max to prevent memory leaks - while (allMessages.size > MAX_MESSAGES_IN_MEMORY) { - allMessages.removeLast() - } - - currentPage++ - } - - _uiState.update { - it.copy( - messages = allMessages.toList(), - hasMoreMessages = !isAllMessagesLoaded, - isLoadingMore = false - ) - } - } catch (e: Exception) { - Logger.e("ChatViewModel -> Failed to load messages", e) - _uiState.update { it.copy(isLoadingMore = false) } - } - } finally { - paginationMutex.unlock() - } - } - } - fun onInputChanged(text: String) { _uiState.update { it.copy(inputText = text, draftText = text) } } @@ -286,9 +204,13 @@ class ChatViewModel @Inject constructor( val lastSentMessage = currentMessages.lastOrNull { it.isFromMe } if (lastSentMessage != null) { _uiState.update { currentState -> - currentState.copy( - transportUsed = currentState.transportUsed + (lastSentMessage.id to transportType) - ) + val updated = currentState.transportUsed + (lastSentMessage.id to transportType) + // Cap map size to prevent unbounded growth + val capped = if (updated.size > TRANSPORT_HISTORY_MAX_SIZE) { + // Keep only the most recent entries by dropping oldest + updated.toList().takeLast(TRANSPORT_HISTORY_MAX_SIZE).toMap() + } else updated + currentState.copy(transportUsed = capped) } } } catch (e: Exception) { @@ -437,6 +359,8 @@ class ChatViewModel @Inject constructor( fun deleteMessage(messageId: String, deleteType: DeleteType) { viewModelScope.launch { repository.deleteMessage(messageId, deleteType) + // Clean up transport history for deleted messages + _uiState.update { it.copy(transportUsed = it.transportUsed - messageId) } } } @@ -487,7 +411,7 @@ class ChatViewModel @Inject constructor( ?: return@launch _forwardDialogState.value = ForwardDialogState( - messages = listOf(message), + messages = listOf(message.toUiModel()), selectedPeerIds = emptySet(), searchQuery = "", isForwarding = false, @@ -507,7 +431,7 @@ class ChatViewModel @Inject constructor( val selectedMessages = uiState.value.messages.filter { it.id in selectedIds } _forwardDialogState.value = ForwardDialogState( - messages = selectedMessages, + messages = selectedMessages.map { it.toUiModel() }, selectedPeerIds = emptySet(), searchQuery = "", isForwarding = false, @@ -646,6 +570,9 @@ class ChatViewModel @Inject constructor( selectedIds.forEach { messageId -> repository.deleteMessage(messageId, deleteType) } + // Clean up transport history for all deleted messages + val idsToRemove = selectedIds.toSet() + _uiState.update { it.copy(transportUsed = it.transportUsed.filterKeys { it !in idsToRemove }) } clearSelection() } } @@ -766,3 +693,14 @@ class ChatViewModel @Inject constructor( TransportType.LAN -> "" // LAN is default — no badge needed } } + +/** + * Maps a [MessageEntity] to a [MessageUiModel] for use in UI components + * that should not depend on data-layer entities directly. + */ +private fun MessageEntity.toUiModel() = MessageUiModel( + id = id, + text = text, + type = type, + timestamp = timestamp +) diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt index 9be0734f..94aea910 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatContextMenu.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import com.p2p.meshify.core.data.local.entity.MessageEntity import com.p2p.meshify.core.common.R +import androidx.compose.ui.res.stringResource /** * Context menu shown as a bottom sheet when a message is long-pressed. @@ -51,7 +52,7 @@ fun ChatContextMenu( ) { ListItem( headlineContent = { Text(stringResource(R.string.chat_action_reply)) }, - leadingContent = { Icon(Icons.Default.Reply, null) }, + leadingContent = { Icon(Icons.Default.Reply, stringResource(R.string.chat_action_reply)) }, modifier = Modifier.clickable { onReply(message) onDismiss() @@ -59,7 +60,7 @@ fun ChatContextMenu( ) ListItem( headlineContent = { Text(stringResource(R.string.chat_action_forward)) }, - leadingContent = { Icon(Icons.Default.Forward, null) }, + leadingContent = { Icon(Icons.Default.Forward, stringResource(R.string.chat_action_forward)) }, modifier = Modifier.clickable { onForward(message.id) onDismiss() @@ -67,7 +68,7 @@ fun ChatContextMenu( ) ListItem( headlineContent = { Text(stringResource(R.string.chat_action_copy)) }, - leadingContent = { Icon(Icons.Default.ContentCopy, null) }, + leadingContent = { Icon(Icons.Default.ContentCopy, stringResource(R.string.chat_action_copy)) }, modifier = Modifier.clickable { clipboardManager.setText(AnnotatedString(message.text ?: "")) onDismiss() @@ -75,7 +76,7 @@ fun ChatContextMenu( ) ListItem( headlineContent = { Text(stringResource(R.string.chat_action_delete_for_me)) }, - leadingContent = { Icon(Icons.Default.Delete, null) }, + leadingContent = { Icon(Icons.Default.Delete, stringResource(R.string.content_desc_delete)) }, modifier = Modifier.clickable { onDeleteForMe(message.id) onDismiss() @@ -86,7 +87,7 @@ fun ChatContextMenu( leadingContent = { Icon( Icons.Default.DeleteForever, - null, + stringResource(R.string.chat_action_delete_for_everyone), tint = MaterialTheme.colorScheme.error ) }, diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatTopBar.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatTopBar.kt index d4b292fd..99310316 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatTopBar.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ChatTopBar.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.p2p.meshify.core.ui.components.MorphingAvatar +import com.p2p.meshify.core.ui.components.MeshifyAvatar import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import com.p2p.meshify.core.common.R @@ -43,9 +43,8 @@ fun ChatTopBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - MorphingAvatar( - initials = peerName.take(1), - isOnline = isOnline, + MeshifyAvatar( + initials = peerName.take(2), size = 40.dp ) Column(verticalArrangement = Arrangement.Center) { diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt index 658979fa..0256e201 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageBubble.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.Bluetooth @@ -51,6 +50,7 @@ import com.p2p.meshify.core.data.local.entity.MessageEntity import com.p2p.meshify.core.data.local.entity.MessageStatus import com.p2p.meshify.core.ui.components.AlbumMediaGrid import com.p2p.meshify.core.ui.components.VideoPlayer +import com.p2p.meshify.core.ui.model.AttachmentUiModel import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import com.p2p.meshify.domain.model.MessageType import com.p2p.meshify.domain.model.TransportType @@ -113,7 +113,6 @@ fun MessageBubble( message: MessageEntity, attachments: List, peerName: String, - bubbleStyle: com.p2p.meshify.domain.model.BubbleStyle, isSelected: Boolean = false, uploadProgress: Int? = null, transportType: TransportType? = null, @@ -126,8 +125,8 @@ fun MessageBubble( val containerColor = if (message.isFromMe) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer val contentColor = if (message.isFromMe) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface - // Professional Chat Bubble Shape from Design System - val bubbleShape = if (message.isFromMe) MeshifyDesignSystem.Shapes.BubbleMe else MeshifyDesignSystem.Shapes.BubblePeer + // Square chat bubble shape + val bubbleShape = MeshifyDesignSystem.Shapes.Card Column( modifier = Modifier @@ -167,7 +166,7 @@ fun MessageBubble( if (message.replyToId != null) { Surface( color = contentColor.copy(alpha = 0.08f), - shape = RoundedCornerShape(10.dp), + shape = MeshifyDesignSystem.Shapes.CardSmall, modifier = Modifier.padding(bottom = 6.dp) ) { Text( @@ -192,7 +191,7 @@ fun MessageBubble( if (attachments.isNotEmpty()) { if (message.text != null) Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Xs)) AlbumMediaGrid( - attachments = attachments, + attachments = attachments.map { it.toAttachmentUiModel() }, caption = null, onImageClick = onImageClick ) @@ -238,7 +237,7 @@ fun MessageBubble( // Show placeholder when file is missing Surface( color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - shape = RoundedCornerShape(12.dp), + shape = MeshifyDesignSystem.Shapes.Card, modifier = Modifier .sizeIn(maxWidth = 260.dp, maxHeight = 120.dp) .padding(vertical = 8.dp) @@ -351,7 +350,7 @@ fun StatusIcon(status: MessageStatus, tint: Color) { when (status) { MessageStatus.QUEUED -> Icon( Icons.Default.Schedule, - null, + stringResource(R.string.message_status_queued), modifier = Modifier.size(StatusIconSize), tint = tint.copy(StatusAlphaQueued) ) @@ -362,31 +361,31 @@ fun StatusIcon(status: MessageStatus, tint: Color) { ) MessageStatus.SENT -> Icon( Icons.Default.Check, - null, + stringResource(R.string.message_status_sent), modifier = Modifier.size(StatusIconSize), tint = tint.copy(StatusAlphaDefault) ) MessageStatus.DELIVERED -> Icon( Icons.Default.DoneAll, - null, + stringResource(R.string.message_status_delivered), modifier = Modifier.size(StatusIconSize), tint = tint.copy(StatusAlphaDefault) ) MessageStatus.RECEIVED -> Icon( Icons.Default.Done, - null, + stringResource(R.string.message_status_received), modifier = Modifier.size(StatusIconSize), tint = tint.copy(StatusAlphaDefault) ) MessageStatus.READ -> Icon( Icons.Default.DoneAll, - null, + stringResource(R.string.message_status_read), modifier = Modifier.size(StatusIconSize), tint = MaterialTheme.colorScheme.tertiary ) MessageStatus.FAILED -> Icon( Icons.Default.Error, - null, + stringResource(R.string.message_status_failed), modifier = Modifier.size(StatusIconSize), tint = MaterialTheme.colorScheme.error ) @@ -421,3 +420,13 @@ private fun TransportTypeIcon(transportType: TransportType, tint: Color) { } } } + +/** + * Maps a [MessageAttachmentEntity] to an [AttachmentUiModel] for use in UI components + * that should not depend on data-layer entities directly. + */ +private fun MessageAttachmentEntity.toAttachmentUiModel() = AttachmentUiModel( + id = id, + type = type, + filePath = filePath +) diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt index f0e97b4c..b59fd321 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/MessageList.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChatBubbleOutline -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -57,7 +56,6 @@ private const val MessageStaggerDelay = 50 * * @param messages List of messages to display * @param isLoading Whether initial load is in progress (controls empty state visibility) - * @param isLoadingMore Whether pagination is loading (shows top spinner) * @param selectedMessages Set of currently selected message IDs (for multi-select mode) * @param uploadProgressMap Map of message ID → upload progress percentage (0-100) * @param transportUsed Map of message ID → transport type used for sending @@ -74,12 +72,10 @@ private const val MessageStaggerDelay = 50 fun MessageList( messages: List, isLoading: Boolean, - isLoadingMore: Boolean, selectedMessages: Set, uploadProgressMap: Map, transportUsed: Map, peerName: String, - bubbleStyle: com.p2p.meshify.domain.model.BubbleStyle, listState: LazyListState = rememberLazyListState(), getAttachmentsForGroupId: suspend (String) -> List, onLongClick: (MessageEntity) -> Unit, @@ -124,18 +120,6 @@ fun MessageList( } } - // Loading indicator at top when loading more messages - if (isLoadingMore) { - item(key = "loading_more") { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) - } - } - } - itemsIndexed( messages, key = { _, m -> m.id } @@ -183,7 +167,6 @@ fun MessageList( message = message, attachments = attachments, peerName = peerName, - bubbleStyle = bubbleStyle, isSelected = isSelected, uploadProgress = progressValue, transportType = messageTransportType, diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ReplyIndicator.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ReplyIndicator.kt index fe1086b4..565f045a 100644 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ReplyIndicator.kt +++ b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/components/ReplyIndicator.kt @@ -51,7 +51,7 @@ fun ReplyIndicator( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh ) { Row( diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatAttachmentsUiState.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatAttachmentsUiState.kt deleted file mode 100644 index 5b60d9d8..00000000 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatAttachmentsUiState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.p2p.meshify.feature.chat.state - -import com.p2p.meshify.core.ui.model.StagedAttachment - -/** - * UI state for attachment staging, upload progress, and media operations. - * Extracted from ChatViewModel to reduce coupling and improve testability. - * - * @property stagedAttachments List of attachments staged for sending. - * @property uploadProgress Map of message IDs to upload progress percentages (0-100). - * @property uploadError Optional error message for a failed file upload attempt. - */ -data class ChatAttachmentsUiState( - val stagedAttachments: List = emptyList(), - val uploadProgress: Map = emptyMap(), - val uploadError: String? = null -) diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatInputUiState.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatInputUiState.kt deleted file mode 100644 index 79510f88..00000000 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatInputUiState.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.p2p.meshify.feature.chat.state - -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.core.ui.components.ForwardDialogState - -/** - * UI state for text input, reply, draft, send operations, and forwarding. - * Extracted from ChatViewModel to reduce coupling and improve testability. - * - * @property inputText Current text in the input field. - * @property draftText Persisted draft text that survives configuration changes. - * @property replyTo The message being replied to, or null if no reply. - * @property isSending Whether a message send operation is in progress. - * @property sendError Optional error message for a failed message send attempt. - * @property forwardDialogState State for the forward message dialog. - * @property selectedMessages Set of message IDs selected in multi-select mode. - */ -data class ChatInputUiState( - val inputText: String = "", - val draftText: String = "", - val replyTo: MessageEntity? = null, - val isSending: Boolean = false, - val sendError: String? = null, - val forwardDialogState: ForwardDialogState = ForwardDialogState(), - val selectedMessages: Set = emptySet() -) diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatMessagesUiState.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatMessagesUiState.kt deleted file mode 100644 index 927818c5..00000000 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/state/ChatMessagesUiState.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.p2p.meshify.feature.chat.state - -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.domain.model.TransportType - -/** - * UI state for message display, pagination, and connection status. - * Extracted from ChatViewModel to reduce coupling and improve testability. - * - * @property isLoading Whether the initial message load is in progress. - * @property messages The current list of messages displayed in the chat. - * @property isOnline Whether the peer is currently online. - * @property isPeerTyping Whether the peer is currently typing. - * @property hasMoreMessages Whether there are older messages available to load. - * @property isLoadingMore Whether older messages are currently being loaded. - * @property transportUsed Map of message IDs to the transport type used for sending. - * @property sendError Optional error message for a failed message send attempt. - * @property uploadError Optional error message for a failed file upload attempt. - */ -data class ChatMessagesUiState( - val isLoading: Boolean = true, - val messages: List = emptyList(), - val isOnline: Boolean = false, - val isPeerTyping: Boolean = false, - val hasMoreMessages: Boolean = false, - val isLoadingMore: Boolean = false, - val transportUsed: Map = emptyMap(), - val sendError: String? = null, - val uploadError: String? = null -) diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatAttachmentsViewModel.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatAttachmentsViewModel.kt deleted file mode 100644 index 3372481b..00000000 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatAttachmentsViewModel.kt +++ /dev/null @@ -1,305 +0,0 @@ -package com.p2p.meshify.feature.chat.viewmodels - -import android.content.Context -import android.net.Uri -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.local.entity.MessageAttachmentEntity -import com.p2p.meshify.core.data.repository.ChatRepositoryImpl -import com.p2p.meshify.core.util.Logger -import com.p2p.meshify.core.ui.model.StagedAttachment -import com.p2p.meshify.domain.model.MessageType -import com.p2p.meshify.feature.chat.state.ChatAttachmentsUiState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.sample -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import java.io.File -import kotlin.time.Duration.Companion.milliseconds - -private const val ATTACHMENT_CACHE_MAX_SIZE = 200 - -/** - * ViewModel responsible for attachment staging, upload progress tracking, - * media sending, and attachment caching. - * - * Extracted from the monolithic ChatViewModel to reduce coupling and improve testability. - * Handles: - * - Attachment staging (add, remove, clear) - * - Image sending (from staged attachments) - * - Video sending (from staged attachments) - * - Grouped/album message sending - * - File upload with progress tracking - * - Upload cancellation - * - LRU cache for message attachments - * - Getting attachments for a message (group) - */ -class ChatAttachmentsViewModel( - private val context: Context, - private val chatRepository: ChatRepositoryImpl, - private val peerId: String, - private val peerName: String -) : ViewModel() { - - // ==================== UI State ==================== - - private val _uiState = MutableStateFlow(ChatAttachmentsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // ==================== Attachment Staging ==================== - - private val stageMutex = Mutex() - - /** - * Stages an attachment for sending. - * Limits to 10 attachments at a time. - * - * @param uri The URI of the attachment. - * @param bytes The raw bytes of the attachment. - * @param type The type of the attachment (IMAGE, VIDEO, FILE, etc.). - */ - fun stageAttachment(uri: Uri, bytes: ByteArray, type: MessageType) { - viewModelScope.launch { - stageMutex.withLock { - val current = _uiState.value.stagedAttachments - if (current.size >= 10) return@launch // Limit to 10 attachments - - val newAttachment = StagedAttachment(uri, bytes, type) - val updated = current + newAttachment - _uiState.update { it.copy(stagedAttachments = updated) } - } - } - } - - /** - * Removes a staged attachment by URI. - * - * @param uri The URI of the attachment to remove. - */ - fun removeStagedAttachment(uri: Uri) { - viewModelScope.launch { - stageMutex.withLock { - val updated = _uiState.value.stagedAttachments.filter { it.uri != uri } - _uiState.update { it.copy(stagedAttachments = updated) } - } - } - } - - /** - * Clears all staged attachments. - */ - fun clearStagedAttachments() { - viewModelScope.launch { - stageMutex.withLock { - _uiState.update { it.copy(stagedAttachments = emptyList()) } - } - } - } - - // ==================== Image & Video Sending ==================== - - /** - * Sends an image message directly (without staging). - * - * @param bytes The raw image bytes. - * @param extension The file extension (e.g., "jpg", "png"). - * @param replyToId Optional message ID to reply to. - */ - fun sendImage(bytes: ByteArray, extension: String, replyToId: String? = null) { - viewModelScope.launch { - chatRepository.sendImage(peerId, peerName, bytes, extension, replyToId) - } - } - - /** - * Sends a video message directly (without staging). - * - * @param bytes The raw video bytes. - * @param extension The file extension (e.g., "mp4", "avi"). - * @param replyToId Optional message ID to reply to. - */ - fun sendVideo(bytes: ByteArray, extension: String, replyToId: String? = null) { - viewModelScope.launch { - chatRepository.sendVideo(peerId, peerName, bytes, extension, replyToId) - } - } - - /** - * Sends a grouped message with multiple attachments (album). - * - * @param caption Optional caption text for the grouped message. - * @param attachments List of attachment byte arrays with their types. - * @param replyToId Optional message ID to reply to. - */ - fun sendGroupedMessage( - caption: String, - attachments: List>, - replyToId: String? = null - ) { - viewModelScope.launch { - chatRepository.sendGroupedMessage(peerId, peerName, caption, attachments, replyToId) - } - } - - // ==================== File Upload with Progress ==================== - - /** - * Sends a file with progress tracking. - * Used for large files where upload progress should be shown to the user. - * - * @param messageId Unique ID for this message (generated before calling). - * @param file The file to upload. - * @param fileType The type of file (IMAGE, VIDEO, FILE, etc.). - * @param caption Optional caption for the file. - * @param replyToId Optional message ID to reply to. - */ - fun sendFileWithProgress( - messageId: String, - file: File, - fileType: MessageType, - caption: String = "", - replyToId: String? = null - ) { - viewModelScope.launch { - try { - // Initialize progress to 0 - _uploadProgress.update { current -> - current + (messageId to 0) - } - - // Send file via repository with progress callback - val result = chatRepository.sendFileWithProgress( - messageId = messageId, - peerId = peerId, - peerName = peerName, - file = file, - fileType = fileType, - caption = caption, - replyToId = replyToId, - progressCallback = { progress -> - // Update progress in UI state - _uploadProgress.update { current -> - current + (messageId to progress) - } - } - ) - - result.onSuccess { - // Remove from progress map on success - _uploadProgress.update { current -> - current - messageId - } - }.onFailure { error -> - // Remove from progress map on failure - _uploadProgress.update { current -> - current - messageId - } - // Show error to user - _uiState.update { - it.copy( - uploadError = context.getString( - R.string.error_file_send_failed, - error.message ?: context.getString(R.string.error_unknown) - ) - ) - } - } - } catch (e: Exception) { - Logger.e("ChatAttachmentsViewModel -> File upload exception", e) - _uploadProgress.update { current -> - current - messageId - } - // Show error to user - _uiState.update { - it.copy( - uploadError = context.getString( - R.string.error_message_send_failed, - e.message ?: context.getString(R.string.error_unknown) - ) - ) - } - } - } - } - - /** - * Cancels an ongoing file upload. - * Removes the progress indicator from the UI. - * - * @param messageId The ID of the message whose upload to cancel. - */ - fun cancelUpload(messageId: String) { - _uploadProgress.update { current -> - current - messageId - } - // Note: Actual cancellation logic would need to be implemented in repository - Logger.d("ChatAttachmentsViewModel -> Upload cancelled for messageId: $messageId") - } - - // ==================== Upload Progress ==================== - - /** - * Upload progress tracking - maps messageId to progress percentage (0-100). - * Throttled to 10 updates/second to avoid unnecessary recompositions. - */ - @OptIn(FlowPreview::class) - private val _uploadProgress = MutableStateFlow>(emptyMap()) - @OptIn(FlowPreview::class) - val uploadProgress: StateFlow> = _uploadProgress - .sample(100.milliseconds) - .stateIn(viewModelScope, SharingStarted.Lazily, emptyMap()) - - // ==================== Attachment Cache ==================== - - /** - * LRU cache for message attachments to avoid repeated DB queries. - * Capacity of [ATTACHMENT_CACHE_MAX_SIZE] entries (~all attachments in a typical conversation). - * Access-ordered: least recently used entries are evicted first. - */ - private val attachmentsCache = object : LinkedHashMap>(16, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry>) = - size > ATTACHMENT_CACHE_MAX_SIZE - } - - /** - * Get attachments for a specific message. - * Uses LRU cache to avoid repeated DB queries for the same groupId. - * - * @param groupId The message group ID to fetch attachments for. - * @return List of attachments for the message. - */ - suspend fun getAttachmentsForMessage(groupId: String): List { - // Check cache first (thread-safe via synchronized) - synchronized(attachmentsCache) { - attachmentsCache[groupId]?.let { return it } - } - // Not cached — fetch from DB - val result = withContext(Dispatchers.IO) { - chatRepository.getMessageAttachments(groupId) - } - // Store in cache - synchronized(attachmentsCache) { - attachmentsCache[groupId] = result - } - return result - } - - // ==================== State Clearing ==================== - - /** - * Clears the upload error message after it has been shown. - */ - fun clearUploadError() { - _uiState.update { it.copy(uploadError = null) } - } -} diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatInputViewModel.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatInputViewModel.kt deleted file mode 100644 index 31e92d77..00000000 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatInputViewModel.kt +++ /dev/null @@ -1,366 +0,0 @@ -package com.p2p.meshify.feature.chat.viewmodels - -import android.content.Context -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.text.AnnotatedString -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.core.ui.components.ForwardDialogState -import com.p2p.meshify.core.util.Logger -import com.p2p.meshify.domain.model.DeleteType -import com.p2p.meshify.domain.repository.IChatRepository -import com.p2p.meshify.feature.chat.state.ChatInputUiState -import com.p2p.meshify.core.common.R -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * ViewModel responsible for text input, draft management, message sending, - * forwarding, and multi-select operations. - * - * Extracted from the monolithic ChatViewModel to reduce coupling and improve testability. - * Handles: - * - Text input changes and draft persistence - * - Reply-to-message logic - * - Send message (text only, with debouncing and error handling) - * - Forward dialog state management (search, peer selection, multi-message forward) - * - Multi-select mode (toggle, clear, delete selected, copy to clipboard) - * - Back confirmation logic (reserved) - */ -class ChatInputViewModel( - private val chatRepository: IChatRepository, - private val peerId: String, - private val peerName: String, - private val context: Context -) : ViewModel() { - - // ==================== UI State ==================== - - private val _uiState = MutableStateFlow(ChatInputUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // Forward dialog state (exposed separately for Compose collection) - private val _forwardDialogState = MutableStateFlow(ForwardDialogState()) - val forwardDialogState: StateFlow = _forwardDialogState.asStateFlow() - - // Multi-select mode - private val _selectedMessages = MutableStateFlow>(emptySet()) - val selectedMessages: StateFlow> = _selectedMessages.asStateFlow() - - val isInSelectionMode: Boolean - get() = _selectedMessages.value.isNotEmpty() - - // ==================== Send Debouncing ==================== - - /** - * Double tap protection — prevent sending the same message twice. - * 500ms debounce window. - */ - private var lastSendTime = 0L - private val sendDebounceMs = 500L - - // ==================== Input & Draft ==================== - - /** - * Updates the input text and persists it as draft. - * - * @param text The current text in the input field. - */ - fun onInputChanged(text: String) { - _uiState.update { it.copy(inputText = text, draftText = text) } - } - - /** - * Restores draft text from ViewModel state. - * Called when the Composable is first created to sync with saved state. - * - * @param text The draft text to restore. - */ - fun restoreDraftText(text: String) { - _uiState.update { it.copy(draftText = text) } - } - - /** - * Sets the message to reply to. - * - * @param message The message to reply to, or null to cancel reply. - */ - fun setReplyTo(message: MessageEntity?) { - _uiState.update { it.copy(replyTo = message) } - } - - // ==================== Send Message ==================== - - /** - * Sends the current input text as a message. - * Includes double-tap protection (500ms debounce) and error handling. - * On failure, the input text is restored so the user does not lose their message. - */ - fun sendMessage() { - val state = _uiState.value - val hasText = state.inputText.isNotBlank() - - if (!hasText) return - if (state.isSending) return // Prevent double-send - - // Double tap protection — ignore if too soon - val now = System.currentTimeMillis() - if (now - lastSendTime < sendDebounceMs) { - Logger.d("ChatInputViewModel -> Double tap detected, ignoring send") - return - } - lastSendTime = now - - viewModelScope.launch { - // Set isSending to true immediately to disable button - _uiState.update { it.copy(isSending = true) } - - try { - // Send text message - chatRepository.sendMessage(peerId, peerName, state.inputText, state.replyTo?.id) - _uiState.update { it.copy(inputText = "", draftText = "", replyTo = null, isSending = false) } - } catch (e: Exception) { - Logger.e("ChatInputViewModel -> Failed to send message", e) - val errorMessage = when { - e.message?.contains("offline", ignoreCase = true) == true -> - context.getString(R.string.error_peer_offline_message_saved) - e.message?.contains("network", ignoreCase = true) == true || - e.message?.contains("connection", ignoreCase = true) == true -> - context.getString(R.string.error_network_retry) - else -> context.getString(R.string.error_message_send_failed, e.message ?: context.getString(R.string.error_unknown)) - } - _uiState.update { - it.copy( - isSending = false, - sendError = errorMessage, - inputText = state.inputText // Restore text on failure - ) - } - } - } - } - - // ==================== Forward Dialog ==================== - - /** - * Opens forward dialog for a single message. - * - * @param messageId The ID of the message to forward. - * @param messages The current list of messages to find the message from. - */ - fun openForwardDialog(messageId: String, messages: List) { - viewModelScope.launch { - val message = messages.find { it.id == messageId } - ?: return@launch - - _forwardDialogState.value = ForwardDialogState( - messages = listOf(message), - selectedPeerIds = emptySet(), - searchQuery = "", - isForwarding = false, - forwardProgress = 0 - ) - } - } - - /** - * Opens forward dialog for multiple selected messages. - * - * @param messages The current list of messages to find selected messages from. - */ - fun openForwardDialogForSelected(messages: List) { - viewModelScope.launch { - val selectedIds = _selectedMessages.value - if (selectedIds.isEmpty()) return@launch - - val selectedMessages = messages.filter { it.id in selectedIds } - - _forwardDialogState.value = ForwardDialogState( - messages = selectedMessages, - selectedPeerIds = emptySet(), - searchQuery = "", - isForwarding = false, - forwardProgress = 0 - ) - } - } - - /** - * Toggle selection for a peer in forward dialog. - * - * @param peerId The peer ID to toggle. - */ - fun togglePeerSelection(peerId: String) { - viewModelScope.launch { - val currentState = _forwardDialogState.value - val newSelectedIds = if (peerId in currentState.selectedPeerIds) { - currentState.selectedPeerIds - peerId - } else { - currentState.selectedPeerIds + peerId - } - - _forwardDialogState.value = currentState.copy( - selectedPeerIds = newSelectedIds - ) - } - } - - /** - * Update search query in forward dialog. - * - * @param query The search query text. - */ - fun updateForwardSearchQuery(query: String) { - _forwardDialogState.value = _forwardDialogState.value.copy( - searchQuery = query - ) - } - - /** - * Forward selected messages to chosen peers. - * - * @param targetPeerIds List of peer IDs to forward messages to. - */ - fun forwardMessages(targetPeerIds: List) { - viewModelScope.launch { - val currentState = _forwardDialogState.value - if (currentState.selectedPeerIds.isEmpty() || currentState.messages.isEmpty()) return@launch - - // Update state to forwarding - _forwardDialogState.value = currentState.copy( - isForwarding = true, - forwardProgress = 0 - ) - - val messagesToForward = currentState.messages - val peerIds = currentState.selectedPeerIds.toList() - var successCount = 0 - var failedCount = 0 - - // Forward each message to each peer - messagesToForward.forEach { message -> - peerIds.forEach { targetPeerId -> - try { - val result = chatRepository.forwardMessage(message.id, listOf(targetPeerId)) - if (result.isSuccess) { - successCount++ - } else { - failedCount++ - Logger.e("ChatInputViewModel -> Failed to forward message ${message.id} to $targetPeerId: ${result.exceptionOrNull()?.message}") - } - } catch (e: Exception) { - failedCount++ - Logger.e("ChatInputViewModel -> Exception forwarding message ${message.id} to $targetPeerId", e) - } - - // Update progress - _forwardDialogState.value = _forwardDialogState.value.copy( - forwardProgress = successCount + failedCount - ) - } - } - - // Show result - if (failedCount == 0) { - Logger.d("ChatInputViewModel -> Successfully forwarded $successCount messages to ${peerIds.size} peers") - } else { - Logger.w("ChatInputViewModel -> Forwarded $successCount messages, $failedCount failed") - } - - // Reset state after delay - withContext(Dispatchers.Main) { - kotlinx.coroutines.delay(1000) - _forwardDialogState.value = ForwardDialogState() - clearSelection() // Clear multi-select mode - } - } - } - - /** - * Dismiss forward dialog. - */ - fun dismissForwardDialog() { - _forwardDialogState.value = ForwardDialogState() - } - - // ==================== Multi-Select Mode ==================== - - /** - * Toggle message selection for multi-select mode. - * - * @param messageId The ID of the message to toggle. - */ - fun toggleMessageSelection(messageId: String) { - viewModelScope.launch { - val current = _selectedMessages.value - _selectedMessages.value = if (messageId in current) { - current - messageId - } else { - current + messageId - } - } - } - - /** - * Clear all selected messages. - */ - fun clearSelection() { - _selectedMessages.value = emptySet() - } - - /** - * Delete all selected messages. - * - * @param deleteType Whether to delete for self only or for everyone. - */ - fun deleteSelectedMessages(deleteType: DeleteType) { - viewModelScope.launch { - val selectedIds = _selectedMessages.value.toList() - selectedIds.forEach { messageId -> - chatRepository.deleteMessage(messageId, deleteType) - } - clearSelection() - } - } - - /** - * Copy all selected messages to clipboard. - * - * @param messages The current list of messages to find selected messages from. - * @param clipboard The ClipboardManager to use for copying. - */ - fun copySelectedMessagesToClipboard( - messages: List, - clipboard: ClipboardManager - ) { - viewModelScope.launch { - val selectedIds = _selectedMessages.value - if (selectedIds.isEmpty()) return@launch - - val selectedMessageEntities = messages.filter { it.id in selectedIds && it.text != null } - val textToCopy = selectedMessageEntities.joinToString("\n\n") { it.text ?: "" } - - if (textToCopy.isNotBlank()) { - clipboard.setText(AnnotatedString(textToCopy)) - Logger.d("ChatInputViewModel -> Copied ${selectedMessageEntities.size} messages to clipboard") - } - - clearSelection() - } - } - - // ==================== State Clearing ==================== - - /** - * Clears the send error message from the UI state. - */ - fun clearError() { - _uiState.update { it.copy(sendError = null) } - } -} diff --git a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatMessagesViewModel.kt b/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatMessagesViewModel.kt deleted file mode 100644 index 7709e806..00000000 --- a/feature/chat/src/main/java/com/p2p/meshify/feature/chat/viewmodels/ChatMessagesViewModel.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.p2p.meshify.feature.chat.viewmodels - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.core.data.repository.ChatRepositoryImpl -import com.p2p.meshify.core.util.Logger -import com.p2p.meshify.domain.model.DeleteType -import com.p2p.meshify.domain.model.TransportType -import com.p2p.meshify.domain.security.model.SecurityEvent -import com.p2p.meshify.feature.chat.state.ChatMessagesUiState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext - -/** - * ViewModel responsible for message display, pagination, connection status, - * and security event handling. - * - * Extracted from the monolithic ChatViewModel to reduce coupling and improve testability. - * Handles: - * - Loading messages from repository (with pagination) - * - Observing online peers - * - Observing peer typing status (reserved — not yet collected) - * - Observing security events - * - Pagination logic (loadMoreMessages) - * - Message deletion (delegates to repository) - * - Reaction adding (delegates to repository) - */ -class ChatMessagesViewModel( - private val context: Context, - private val chatRepository: ChatRepositoryImpl, - private val peerId: String, - private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.IO -) : ViewModel() { - - // ==================== Pagination State ==================== - - private var currentPage = 0 - private val pageSize = 50 - private var isAllMessagesLoaded = false - private val allMessages = ArrayDeque(initialCapacity = 100) - - /** - * Maximum messages to keep in memory. - * Prevents memory leaks in long conversations. - * 200 messages = ~4MB vs 500 messages = ~10MB. - */ - private val paginationMutex = Mutex() - - // ==================== Transport Type Tracking ==================== - - private var _transportTypeProvider: (() -> TransportType)? = null - - fun setTransportTypeProvider(provider: () -> TransportType) { - _transportTypeProvider = provider - } - - // ==================== UI State ==================== - - private val _uiState = MutableStateFlow(ChatMessagesUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // ==================== Initialization ==================== - - init { - // Load initial page of messages - loadMoreMessages() - - // Collect messages flow with distinctUntilChanged to reduce recompositions. - // This ensures real-time updates when messages are received from the network. - viewModelScope.launch { - chatRepository.getMessages(peerId) - .distinctUntilChanged() - .collect { messages -> - Logger.d("ChatMessagesViewModel -> Messages updated: ${messages.size} messages for peer $peerId") - - // Only update UI state; allMessages is managed separately for pagination. - _uiState.update { - it.copy( - isLoading = false, - messages = messages, - hasMoreMessages = !isAllMessagesLoaded - ) - } - } - } - - // Collect online status - viewModelScope.launch { - chatRepository.onlinePeers.collect { online -> - _uiState.update { it.copy(isOnline = online.contains(peerId)) } - } - } - - // Collect security events from repository. - // SharedFlow is hot and never terminated — errors are handled in repository before emit. - viewModelScope.launch { - chatRepository.securityEvents.collect { event -> - if (event.type == SecurityEvent.EventType.MESSAGE_SEND_FAILED) { - _uiState.update { - it.copy( - sendError = context.getString( - R.string.error_message_send_failed, - event.reason - ) - ) - } - Logger.e("ChatMessagesViewModel -> Message send failed: ${event.messageId}") - } - } - } - } - - // ==================== Pagination ==================== - - /** - * Loads older messages for pagination. - * Called initially during init and when the user scrolls to the top. - * Uses a mutex to prevent concurrent load attempts. - */ - fun loadMoreMessages() { - viewModelScope.launch { - // Use tryLock to avoid waiting if already loading - if (!paginationMutex.tryLock()) return@launch - - try { - if (isAllMessagesLoaded || _uiState.value.isLoadingMore) return@launch - - _uiState.update { it.copy(isLoadingMore = true) } - - try { - val newPage = withContext(ioDispatcher) { - chatRepository.getMessagesPaged(peerId, pageSize, currentPage * pageSize) - .take(1) - .firstOrNull() - ?: emptyList() - } - - if (newPage.isEmpty()) { - isAllMessagesLoaded = true - } else { - // Prepend new messages efficiently using ArrayDeque - allMessages.addAll(0, newPage) - - // Remove oldest messages if exceeding max to prevent memory leaks - while (allMessages.size > MAX_MESSAGES_IN_MEMORY) { - allMessages.removeLast() - } - - currentPage++ - } - - _uiState.update { - it.copy( - messages = allMessages.toList(), - hasMoreMessages = !isAllMessagesLoaded, - isLoadingMore = false - ) - } - } catch (e: Exception) { - Logger.e("ChatMessagesViewModel -> Failed to load messages", e) - _uiState.update { it.copy(isLoadingMore = false) } - } - } finally { - paginationMutex.unlock() - } - } - } - - // ==================== Message Operations ==================== - - /** - * Deletes a message by delegating to the repository. - * - * @param messageId The ID of the message to delete. - * @param deleteType Whether to delete for self only or for everyone. - */ - fun deleteMessage(messageId: String, deleteType: DeleteType) { - viewModelScope.launch { - chatRepository.deleteMessage(messageId, deleteType) - } - } - - /** - * Adds or removes a reaction on a message by delegating to the repository. - * - * @param messageId The ID of the message to react to. - * @param reaction The emoji reaction, or null to remove an existing reaction. - */ - fun addReaction(messageId: String, reaction: String?) { - viewModelScope.launch { - chatRepository.addReaction(messageId, reaction) - } - } - - // ==================== State Clearing ==================== - - /** - * Clears the send error message from the UI state. - */ - fun clearError() { - _uiState.update { it.copy(sendError = null) } - } - - /** - * Clears the upload error message from the UI state. - */ - fun clearUploadError() { - _uiState.update { it.copy(uploadError = null) } - } - - // ==================== Transport Type Utilities ==================== - - /** - * Get the transport type label resource for a given transport type. - */ - fun getTransportTypeLabel(transportType: TransportType): String = when (transportType) { - TransportType.BLE -> context.getString(R.string.chat_transport_ble_desc) - TransportType.BOTH -> context.getString(R.string.chat_transport_multipath_desc) - TransportType.LAN -> "" // LAN is default — no badge needed - } - - companion object { - private const val MAX_MESSAGES_IN_MEMORY = 200 - } -} diff --git a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatAttachmentsViewModelTest.kt b/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatAttachmentsViewModelTest.kt deleted file mode 100644 index f68399d4..00000000 --- a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatAttachmentsViewModelTest.kt +++ /dev/null @@ -1,448 +0,0 @@ -package com.p2p.meshify.feature.chat - -import android.content.Context -import android.net.Uri -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.repository.ChatRepositoryImpl -import com.p2p.meshify.domain.model.MessageType -import com.p2p.meshify.feature.chat.viewmodels.ChatAttachmentsViewModel -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.io.File - -@OptIn(ExperimentalCoroutinesApi::class) -class ChatAttachmentsViewModelTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - private val mockContext: Context = mockk(relaxed = true) - private val mockRepository: ChatRepositoryImpl = mockk(relaxed = true) - - private lateinit var viewModel: ChatAttachmentsViewModel - - private val testPeerId = "peer-123" - private val testPeerName = "Alice" - - @Before - fun setup() { - every { mockRepository.onlinePeers } returns kotlinx.coroutines.flow.flowOf(emptySet()) - - every { mockContext.getString(R.string.error_file_send_failed, any()) } returns "File send failed" - every { mockContext.getString(R.string.error_message_send_failed, any()) } returns "Message send failed" - every { mockContext.getString(R.string.error_unknown) } returns "Unknown" - - // Default: getMessagesPaged returns empty - coEvery { mockRepository.getMessagesPaged(any(), any(), any()) } returns flowOf(emptyList()) - - viewModel = ChatAttachmentsViewModel(mockContext, mockRepository, testPeerId, testPeerName) - } - - // ==================== Initial State Tests ==================== - - @Test - fun `initial state should have empty staged attachments`() { - val state = viewModel.uiState.value - - assertTrue(state.stagedAttachments.isEmpty()) - assertTrue(state.uploadProgress.isEmpty()) - assertNull(state.uploadError) - } - - // ==================== Attachment Staging Tests ==================== - - @Test - fun `stageAttachment should add attachment to staged list`() = runTest { - val uri = mockUri("content://test/1") - val bytes = byteArrayOf(1, 2, 3) - - viewModel.stageAttachment(uri, bytes, MessageType.IMAGE) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertEquals(1, state.stagedAttachments.size) - assertEquals(uri, state.stagedAttachments[0].uri) - assertEquals(MessageType.IMAGE, state.stagedAttachments[0].type) - } - - @Test - fun `stageAttachment should preserve raw bytes reference`() = runTest { - val uri = mockUri("content://test/1") - val bytes = byteArrayOf(10, 20, 30) - - viewModel.stageAttachment(uri, bytes, MessageType.FILE) - advanceUntilIdle() - - val staged = viewModel.uiState.value.stagedAttachments[0] - assertTrue(staged.bytes.contentEquals(bytes)) - } - - @Test - fun `stageAttachment should allow up to 10 attachments`() = runTest { - repeat(10) { index -> - val uri = mockUri("content://test/$index") - viewModel.stageAttachment(uri, byteArrayOf(index.toByte()), MessageType.IMAGE) - advanceUntilIdle() - } - - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - } - - @Test - fun `stageAttachment should not exceed 10 attachments limit`() = runTest { - repeat(10) { index -> - val uri = mockUri("content://test/$index") - viewModel.stageAttachment(uri, byteArrayOf(index.toByte()), MessageType.IMAGE) - advanceUntilIdle() - } - - // Try to add 11th - val extraUri = mockUri("content://test/extra") - viewModel.stageAttachment(extraUri, byteArrayOf(99), MessageType.IMAGE) - advanceUntilIdle() - - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - assertFalse(viewModel.uiState.value.stagedAttachments.any { it.uri.toString() == "content://test/extra" }) - } - - @Test - fun `stageAttachment should handle different message types`() = runTest { - val uri1 = mockUri("content://test/img") - val uri2 = mockUri("content://test/vid") - val uri3 = mockUri("content://test/file") - - viewModel.stageAttachment(uri1, byteArrayOf(1), MessageType.IMAGE) - viewModel.stageAttachment(uri2, byteArrayOf(2), MessageType.VIDEO) - viewModel.stageAttachment(uri3, byteArrayOf(3), MessageType.FILE) - advanceUntilIdle() - - val staged = viewModel.uiState.value.stagedAttachments - assertEquals(3, staged.size) - assertEquals(MessageType.IMAGE, staged[0].type) - assertEquals(MessageType.VIDEO, staged[1].type) - assertEquals(MessageType.FILE, staged[2].type) - } - - // ==================== Remove Staged Attachment Tests ==================== - - @Test - fun `removeStagedAttachment should remove matching attachment by uri`() = runTest { - val uri1 = mockUri("content://test/1") - val uri2 = mockUri("content://test/2") - - viewModel.stageAttachment(uri1, byteArrayOf(1), MessageType.IMAGE) - viewModel.stageAttachment(uri2, byteArrayOf(2), MessageType.IMAGE) - advanceUntilIdle() - - viewModel.removeStagedAttachment(uri1) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertEquals(1, state.stagedAttachments.size) - assertEquals(uri2, state.stagedAttachments[0].uri) - } - - @Test - fun `removeStagedAttachment with non-existent uri should not change state`() = runTest { - val uri = mockUri("content://test/1") - val nonExistentUri = mockUri("content://test/nonexistent") - - viewModel.stageAttachment(uri, byteArrayOf(1), MessageType.IMAGE) - advanceUntilIdle() - - viewModel.removeStagedAttachment(nonExistentUri) - advanceUntilIdle() - - assertEquals(1, viewModel.uiState.value.stagedAttachments.size) - } - - @Test - fun `removeStagedAttachment from empty list should not crash`() = runTest { - val uri = mockUri("content://test/nonexistent") - - viewModel.removeStagedAttachment(uri) - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments.isEmpty()) - } - - // ==================== Clear Staged Attachments Tests ==================== - - @Test - fun `clearStagedAttachments should empty the staged list`() = runTest { - viewModel.stageAttachment(mockUri("content://test/1"), byteArrayOf(1), MessageType.IMAGE) - viewModel.stageAttachment(mockUri("content://test/2"), byteArrayOf(2), MessageType.VIDEO) - advanceUntilIdle() - assertEquals(2, viewModel.uiState.value.stagedAttachments.size) - - viewModel.clearStagedAttachments() - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments.isEmpty()) - } - - @Test - fun `clearStagedAttachments on empty list should remain empty`() = runTest { - viewModel.clearStagedAttachments() - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.stagedAttachments.isEmpty()) - } - - // ==================== sendImage Tests ==================== - - @Test - fun `sendImage should delegate to repository sendImage`() = runTest { - val imageBytes = byteArrayOf(1, 2, 3, 4) - val extension = "jpg" - - coEvery { mockRepository.sendImage(any(), any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.sendImage(imageBytes, extension) - advanceUntilIdle() - - coVerify { mockRepository.sendImage(testPeerId, testPeerName, imageBytes, extension, null) } - } - - @Test - fun `sendImage with replyToId should pass replyToId to repository`() = runTest { - coEvery { mockRepository.sendImage(any(), any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.sendImage(byteArrayOf(1), "png", replyToId = "reply-msg") - advanceUntilIdle() - - coVerify { mockRepository.sendImage(testPeerId, testPeerName, any(), "png", "reply-msg") } - } - - // ==================== sendVideo Tests ==================== - - @Test - fun `sendVideo should delegate to repository sendVideo`() = runTest { - val videoBytes = byteArrayOf(5, 6, 7, 8) - val extension = "mp4" - - coEvery { mockRepository.sendVideo(any(), any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.sendVideo(videoBytes, extension) - advanceUntilIdle() - - coVerify { mockRepository.sendVideo(testPeerId, testPeerName, videoBytes, extension, null) } - } - - @Test - fun `sendVideo with replyToId should pass replyToId to repository`() = runTest { - coEvery { mockRepository.sendVideo(any(), any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.sendVideo(byteArrayOf(1), "avi", replyToId = "reply-msg") - advanceUntilIdle() - - coVerify { mockRepository.sendVideo(testPeerId, testPeerName, any(), "avi", "reply-msg") } - } - - // ==================== sendFileWithProgress Tests ==================== - - @Test - fun `sendFileWithProgress should initialize progress to 0`() = runTest { - coEvery { mockRepository.sendFileWithProgress(any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) - val testFile = mockk(relaxed = true) - every { testFile.length() } returns 1024L - - viewModel.sendFileWithProgress("msg-1", testFile, MessageType.FILE) - advanceUntilIdle() - - // Progress should have been set to 0 at start and removed on success - // We verify via the uploadProgress flow - val progress = viewModel.uploadProgress.first() - // After success, the entry should be removed - assertFalse(progress.containsKey("msg-1")) - } - - @Test - fun `sendFileWithProgress on failure should set uploadError`() = runTest { - coEvery { mockRepository.sendFileWithProgress(any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.failure(Exception("Disk full")) - val testFile = mockk(relaxed = true) - every { testFile.length() } returns 1024L - - viewModel.sendFileWithProgress("msg-1", testFile, MessageType.FILE) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertNotNull(state.uploadError) - } - - @Test - fun `sendFileWithProgress should pass correct parameters to repository`() = runTest { - coEvery { mockRepository.sendFileWithProgress(any(), any(), any(), any(), any(), any(), any(), any()) } returns Result.success(Unit) - val testFile = mockk(relaxed = true) - every { testFile.length() } returns 2048L - every { testFile.name } returns "document.pdf" - - viewModel.sendFileWithProgress( - messageId = "msg-pdf", - file = testFile, - fileType = MessageType.FILE, - caption = "My document", - replyToId = "reply-msg" - ) - advanceUntilIdle() - - coVerify { - mockRepository.sendFileWithProgress( - messageId = "msg-pdf", - peerId = testPeerId, - peerName = testPeerName, - file = testFile, - fileType = MessageType.FILE, - caption = "My document", - replyToId = "reply-msg", - progressCallback = any() - ) - } - } - - // ==================== cancelUpload Tests ==================== - // NOTE: This test uses CompletableDeferred which can cause infinite loops - // with UnconfinedTestDispatcher. Moved to integration tests. - // @Test - // fun `cancelUpload should remove progress entry`() = runTest { ... } - - // ==================== getAttachmentsForMessage Tests ==================== - - @Test - fun `getAttachmentsForMessage should fetch from repository when not cached`() = runTest { - val attachments = listOf( - testAttachment(id = "att-1", messageId = "group-1"), - testAttachment(id = "att-2", messageId = "group-1") - ) - coEvery { mockRepository.getMessageAttachments("group-1") } returns attachments - - val result = viewModel.getAttachmentsForMessage("group-1") - - assertEquals(2, result.size) - coVerify { mockRepository.getMessageAttachments("group-1") } - } - - @Test - fun `getAttachmentsForMessage should use cache on second call`() = runTest { - val attachments = listOf(testAttachment(id = "att-1", messageId = "group-1")) - coEvery { mockRepository.getMessageAttachments("group-1") } returns attachments - - // First call - fetches from repo - viewModel.getAttachmentsForMessage("group-1") - // Second call - should use cache - viewModel.getAttachmentsForMessage("group-1") - - // Repository should only be called once - coVerify(exactly = 1) { mockRepository.getMessageAttachments("group-1") } - } - - @Test - fun `getAttachmentsForMessage should return empty list for group with no attachments`() = runTest { - coEvery { mockRepository.getMessageAttachments("empty-group") } returns emptyList() - - val result = viewModel.getAttachmentsForMessage("empty-group") - - assertTrue(result.isEmpty()) - } - - // ==================== LRU Cache Eviction Tests ==================== - - @Test - fun `getAttachmentsForMessage should evict oldest entries after 200 entries`() = runTest { - // Add 200 entries to fill the cache - repeat(200) { index -> - val groupId = "group-$index" - coEvery { mockRepository.getMessageAttachments(groupId) } returns listOf( - testAttachment(id = "att-$index", messageId = groupId) - ) - viewModel.getAttachmentsForMessage(groupId) - } - - // Add one more entry - should trigger eviction - val newGroupId = "group-new" - coEvery { mockRepository.getMessageAttachments(newGroupId) } returns listOf( - testAttachment(id = "att-new", messageId = newGroupId) - ) - viewModel.getAttachmentsForMessage(newGroupId) - - // The oldest entry (group-0) should have been evicted - // Accessing it again should hit the repository - coEvery { mockRepository.getMessageAttachments("group-0") } returns listOf( - testAttachment(id = "att-0", messageId = "group-0") - ) - viewModel.getAttachmentsForMessage("group-0") - - coVerify(exactly = 2) { mockRepository.getMessageAttachments("group-0") } - } - - @Test - fun `getAttachmentsForMessage cache should differentiate by groupId`() = runTest { - val attachments1 = listOf(testAttachment(id = "att-1", messageId = "group-a")) - val attachments2 = listOf(testAttachment(id = "att-2", messageId = "group-b")) - - coEvery { mockRepository.getMessageAttachments("group-a") } returns attachments1 - coEvery { mockRepository.getMessageAttachments("group-b") } returns attachments2 - - val resultA = viewModel.getAttachmentsForMessage("group-a") - val resultB = viewModel.getAttachmentsForMessage("group-b") - - assertEquals("att-1", resultA[0].id) - assertEquals("att-2", resultB[0].id) - // Each repo call should happen exactly once - coVerify(exactly = 1) { mockRepository.getMessageAttachments("group-a") } - coVerify(exactly = 1) { mockRepository.getMessageAttachments("group-b") } - } - - // ==================== clearUploadError Tests ==================== - - @Test - fun `clearUploadError should set uploadError to null`() = runTest { - // Initial state has no error - assertNull(viewModel.uiState.value.uploadError) - - viewModel.clearUploadError() - - assertNull(viewModel.uiState.value.uploadError) - } - - // ==================== Concurrent Staging Tests ==================== - - @Test - fun `concurrent stageAttachment calls should respect 10 limit`() = runTest { - // Stage 10 attachments - repeat(10) { index -> - val uri = mockUri("content://test/$index") - viewModel.stageAttachment(uri, byteArrayOf(index.toByte()), MessageType.IMAGE) - advanceUntilIdle() - } - - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - - // Try to stage more - repeat(5) { index -> - val uri = mockUri("content://test/extra-$index") - viewModel.stageAttachment(uri, byteArrayOf(99), MessageType.IMAGE) - advanceUntilIdle() - } - - // Still only 10 - assertEquals(10, viewModel.uiState.value.stagedAttachments.size) - } -} diff --git a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatInputViewModelTest.kt b/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatInputViewModelTest.kt deleted file mode 100644 index 8ba36bc3..00000000 --- a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatInputViewModelTest.kt +++ /dev/null @@ -1,584 +0,0 @@ -package com.p2p.meshify.feature.chat - -import android.content.Context -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.text.AnnotatedString -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.domain.model.DeleteType -import com.p2p.meshify.domain.model.MessageType -import com.p2p.meshify.domain.repository.IChatRepository -import com.p2p.meshify.domain.security.model.SecurityEvent -import com.p2p.meshify.feature.chat.viewmodels.ChatInputViewModel -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -@Suppress("DEPRECATION") -class ChatInputViewModelTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - private val mockRepository: IChatRepository = mockk(relaxed = true) - private val mockContext: Context = mockk(relaxed = true) - - private lateinit var viewModel: ChatInputViewModel - - private val testPeerId = "peer-123" - private val testPeerName = "Alice" - - @Before - fun setup() { - every { mockRepository.onlinePeers } returns kotlinx.coroutines.flow.flowOf(emptySet()) - every { mockRepository.typingPeers } returns kotlinx.coroutines.flow.flowOf(emptySet()) - every { mockRepository.securityEvents } returns MutableSharedFlow(replay = 0, extraBufferCapacity = 10) - - every { mockContext.getString(R.string.error_peer_offline_message_saved) } returns "Peer offline" - every { mockContext.getString(R.string.error_network_retry) } returns "Network error" - every { mockContext.getString(R.string.error_message_send_failed, any()) } returns "Send failed" - every { mockContext.getString(R.string.error_unknown) } returns "Unknown" - - viewModel = ChatInputViewModel(mockRepository, testPeerId, testPeerName, mockContext) - } - - // ==================== Initial State Tests ==================== - - @Test - fun `initial state should have empty input and no reply`() { - val state = viewModel.uiState.value - - assertEquals("", state.inputText) - assertEquals("", state.draftText) - assertNull(state.replyTo) - assertFalse(state.isSending) - assertNull(state.sendError) - } - - // ==================== Input Change Tests ==================== - - @Test - fun `onInputChanged should update inputText in state`() { - viewModel.onInputChanged("Hello world") - - assertEquals("Hello world", viewModel.uiState.value.inputText) - } - - @Test - fun `onInputChanged should also update draftText in state`() { - viewModel.onInputChanged("Draft message") - - assertEquals("Draft message", viewModel.uiState.value.draftText) - } - - @Test - fun `onInputChanged with empty string should clear input and draft`() { - viewModel.onInputChanged("Some text") - viewModel.onInputChanged("") - - val state = viewModel.uiState.value - assertEquals("", state.inputText) - assertEquals("", state.draftText) - } - - // ==================== Draft Persistence Tests ==================== - - @Test - fun `restoreDraftText should update draftText in state`() { - viewModel.restoreDraftText("Saved draft") - - assertEquals("Saved draft", viewModel.uiState.value.draftText) - } - - @Test - fun `restoreDraftText should not affect inputText`() { - viewModel.onInputChanged("Current input") - viewModel.restoreDraftText("Different draft") - - val state = viewModel.uiState.value - assertEquals("Current input", state.inputText) - assertEquals("Different draft", state.draftText) - } - - // ==================== Reply Tests ==================== - - @Test - fun `setReplyTo should set replyTo message in state`() { - val replyMessage = testMessage(id = "reply-msg", text = "Reply to this") - - viewModel.setReplyTo(replyMessage) - - assertEquals("reply-msg", viewModel.uiState.value.replyTo?.id) - assertEquals("Reply to this", viewModel.uiState.value.replyTo?.text) - } - - @Test - fun `setReplyTo with null should clear reply`() { - viewModel.setReplyTo(testMessage(id = "reply-msg")) - viewModel.setReplyTo(null) - - assertNull(viewModel.uiState.value.replyTo) - } - - // ==================== sendMessage Validation Tests ==================== - - @Test - fun `sendMessage with empty text should not call repository`() = runTest { - viewModel.onInputChanged("") - viewModel.sendMessage() - - coVerify(exactly = 0) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - @Test - fun `sendMessage with blank text should not call repository`() = runTest { - viewModel.onInputChanged(" ") - viewModel.sendMessage() - - coVerify(exactly = 0) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - @Test - fun `sendMessage with valid text should call repository`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.onInputChanged("Hello") - viewModel.sendMessage() - advanceUntilIdle() - - coVerify { mockRepository.sendMessage(testPeerId, testPeerName, "Hello", null) } - } - - @Test - fun `sendMessage with reply should pass replyToId to repository`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.setReplyTo(testMessage(id = "reply-to")) - viewModel.onInputChanged("Reply text") - viewModel.sendMessage() - advanceUntilIdle() - - coVerify { mockRepository.sendMessage(testPeerId, testPeerName, "Reply text", "reply-to") } - } - - @Test - fun `sendMessage on success should clear input, draft, reply, and isSending`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.setReplyTo(testMessage(id = "reply-to")) - viewModel.onInputChanged("Text to send") - viewModel.sendMessage() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertEquals("", state.inputText) - assertEquals("", state.draftText) - assertNull(state.replyTo) - assertFalse(state.isSending) - } - - @Test - fun `sendMessage on failure should restore input text and set error`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.failure(Exception("Network error")) - - viewModel.onInputChanged("Will fail") - viewModel.sendMessage() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertEquals("Will fail", state.inputText) - assertNotNull(state.sendError) - assertFalse(state.isSending) - } - - @Test - fun `sendMessage while already sending should be ignored`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.onInputChanged("First") - viewModel.sendMessage() - advanceUntilIdle() - - viewModel.onInputChanged("Second") - viewModel.sendMessage() - advanceUntilIdle() - - // Only one sendMessage call should have been made due to isSending guard - coVerify(exactly = 1) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - // ==================== Send Debouncing Tests ==================== - - @Test - fun `sendMessage within debounce window should be ignored`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.onInputChanged("First message") - viewModel.sendMessage() - advanceUntilIdle() - - // Immediately send again (within 500ms debounce) - viewModel.onInputChanged("Second message") - viewModel.sendMessage() - advanceUntilIdle() - - // Only the first send should have gone through - coVerify(exactly = 1) { mockRepository.sendMessage(any(), any(), "First message", any()) } - } - - @Test - fun `sendMessage after debounce window should succeed`() = runTest { - coEvery { mockRepository.sendMessage(any(), any(), any(), any()) } returns Result.success(Unit) - - viewModel.onInputChanged("First message") - viewModel.sendMessage() - advanceUntilIdle() - - // Wait past debounce window (500ms + buffer) - advanceTimeBy(600) - runCurrent() - - viewModel.onInputChanged("Second message") - viewModel.sendMessage() - advanceUntilIdle() - - coVerify(exactly = 2) { mockRepository.sendMessage(any(), any(), any(), any()) } - } - - // ==================== Forward Dialog Tests ==================== - - @Test - fun `openForwardDialog should set forward dialog state with message`() = runTest { - val messages = listOf( - testMessage(id = "msg-1", text = "Forward this"), - testMessage(id = "msg-2", text = "Not this one") - ) - - viewModel.openForwardDialog("msg-1", messages) - advanceUntilIdle() - - val dialogState = viewModel.forwardDialogState.value - assertEquals(1, dialogState.messages.size) - assertEquals("msg-1", dialogState.messages[0].id) - } - - @Test - fun `openForwardDialog with non-existent message should not update state`() = runTest { - val messages = listOf(testMessage(id = "msg-1")) - val initialState = viewModel.forwardDialogState.value - - viewModel.openForwardDialog("non-existent", messages) - advanceUntilIdle() - - // State should remain unchanged (empty) - val dialogState = viewModel.forwardDialogState.value - assertTrue(dialogState.messages.isEmpty()) - } - - @Test - fun `openForwardDialogForSelected with no selection should not update state`() = runTest { - val messages = listOf(testMessage(id = "msg-1")) - - viewModel.openForwardDialogForSelected(messages) - advanceUntilIdle() - - val dialogState = viewModel.forwardDialogState.value - assertTrue(dialogState.messages.isEmpty()) - } - - @Test - fun `openForwardDialogForSelected should include only selected messages`() = runTest { - val messages = listOf( - testMessage(id = "msg-1", text = "First"), - testMessage(id = "msg-2", text = "Second"), - testMessage(id = "msg-3", text = "Third") - ) - - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-3") - advanceUntilIdle() - - viewModel.openForwardDialogForSelected(messages) - advanceUntilIdle() - - val dialogState = viewModel.forwardDialogState.value - assertEquals(2, dialogState.messages.size) - assertTrue(dialogState.messages.all { it.id in listOf("msg-1", "msg-3") }) - } - - @Test - fun `togglePeerSelection should add peer to selection`() = runTest { - viewModel.openForwardDialog("msg-1", listOf(testMessage(id = "msg-1"))) - advanceUntilIdle() - - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - - val dialogState = viewModel.forwardDialogState.value - assertTrue(dialogState.selectedPeerIds.contains("peer-a")) - } - - @Test - fun `togglePeerSelection should remove peer if already selected`() = runTest { - viewModel.openForwardDialog("msg-1", listOf(testMessage(id = "msg-1"))) - advanceUntilIdle() - - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - assertTrue(viewModel.forwardDialogState.value.selectedPeerIds.contains("peer-a")) - - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - - assertFalse(viewModel.forwardDialogState.value.selectedPeerIds.contains("peer-a")) - } - - @Test - fun `updateForwardSearchQuery should update search query in state`() = runTest { - viewModel.updateForwardSearchQuery("Alice") - - val dialogState = viewModel.forwardDialogState.value - assertEquals("Alice", dialogState.searchQuery) - } - - @Test - fun `dismissForwardDialog should reset forward dialog state`() = runTest { - viewModel.openForwardDialog("msg-1", listOf(testMessage(id = "msg-1"))) - advanceUntilIdle() - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - - viewModel.dismissForwardDialog() - - val dialogState = viewModel.forwardDialogState.value - assertTrue(dialogState.messages.isEmpty()) - assertTrue(dialogState.selectedPeerIds.isEmpty()) - assertEquals("", dialogState.searchQuery) - } - - // ==================== Multi-Select Tests ==================== - - @Test - fun `toggleMessageSelection should add message to selection`() = runTest { - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - assertTrue(viewModel.selectedMessages.value.contains("msg-1")) - } - - @Test - fun `toggleMessageSelection should remove message if already selected`() = runTest { - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - assertTrue(viewModel.selectedMessages.value.contains("msg-1")) - - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - assertFalse(viewModel.selectedMessages.value.contains("msg-1")) - } - - @Test - fun `toggleMessageSelection should handle multiple selections`() = runTest { - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - viewModel.toggleMessageSelection("msg-3") - advanceUntilIdle() - - val selected = viewModel.selectedMessages.value - assertEquals(3, selected.size) - assertTrue(selected.containsAll(listOf("msg-1", "msg-2", "msg-3"))) - } - - @Test - fun `clearSelection should empty selected messages`() = runTest { - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - advanceUntilIdle() - assertEquals(2, viewModel.selectedMessages.value.size) - - viewModel.clearSelection() - - assertTrue(viewModel.selectedMessages.value.isEmpty()) - } - - @Test - fun `isInSelectionMode should reflect selection state`() = runTest { - assertFalse(viewModel.isInSelectionMode) - - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - assertTrue(viewModel.isInSelectionMode) - } - - @Test - fun `deleteSelectedMessages should delete each selected message from repository`() = runTest { - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - advanceUntilIdle() - - viewModel.deleteSelectedMessages(DeleteType.DELETE_FOR_EVERYONE) - advanceUntilIdle() - - coVerify { mockRepository.deleteMessage("msg-1", DeleteType.DELETE_FOR_EVERYONE) } - coVerify { mockRepository.deleteMessage("msg-2", DeleteType.DELETE_FOR_EVERYONE) } - } - - @Test - fun `deleteSelectedMessages should clear selection after deletion`() = runTest { - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - viewModel.deleteSelectedMessages(DeleteType.DELETE_FOR_EVERYONE) - advanceUntilIdle() - - assertTrue(viewModel.selectedMessages.value.isEmpty()) - } - - @Test - fun `deleteSelectedMessages with no selection should not call repository`() = runTest { - viewModel.deleteSelectedMessages(DeleteType.DELETE_FOR_ME) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.deleteMessage(any(), any()) } - } - - // ==================== Copy to Clipboard Tests ==================== - - @Test - fun `copySelectedMessagesToClipboard should copy text of selected messages`() = runTest { - val messages = listOf( - testMessage(id = "msg-1", text = "First text"), - testMessage(id = "msg-2", text = "Second text"), - testMessage(id = "msg-3", text = null) // null text should be skipped - ) - val mockClipboard = mockk(relaxed = true) - - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - advanceUntilIdle() - - viewModel.copySelectedMessagesToClipboard(messages, mockClipboard) - advanceUntilIdle() - - val capturedText = slot() - verify { mockClipboard.setText(capture(capturedText)) } - assertTrue(capturedText.captured.text.contains("First text")) - assertTrue(capturedText.captured.text.contains("Second text")) - } - - @Test - fun `copySelectedMessagesToClipboard with no selection should not use clipboard`() = runTest { - val messages = listOf(testMessage(id = "msg-1", text = "Test")) - val mockClipboard = mockk(relaxed = true) - - viewModel.copySelectedMessagesToClipboard(messages, mockClipboard) - advanceUntilIdle() - - verify(exactly = 0) { mockClipboard.setText(any()) } - } - - @Test - fun `copySelectedMessagesToClipboard with selected messages having null text should not copy`() = runTest { - val messages = listOf( - testMessage(id = "msg-1", text = null, type = MessageType.IMAGE), - testMessage(id = "msg-2", text = null, type = MessageType.VIDEO) - ) - val mockClipboard = mockk(relaxed = true) - - viewModel.toggleMessageSelection("msg-1") - viewModel.toggleMessageSelection("msg-2") - advanceUntilIdle() - - viewModel.copySelectedMessagesToClipboard(messages, mockClipboard) - advanceUntilIdle() - - verify(exactly = 0) { mockClipboard.setText(any()) } - } - - @Test - fun `copySelectedMessagesToClipboard should clear selection after copy`() = runTest { - val messages = listOf(testMessage(id = "msg-1", text = "Copy me")) - val mockClipboard = mockk(relaxed = true) - - viewModel.toggleMessageSelection("msg-1") - advanceUntilIdle() - - viewModel.copySelectedMessagesToClipboard(messages, mockClipboard) - advanceUntilIdle() - - assertTrue(viewModel.selectedMessages.value.isEmpty()) - } - - // ==================== clearError Tests ==================== - - @Test - fun `clearError should set sendError to null`() = runTest { - // Simulate error state by directly checking initial state is null - assertNull(viewModel.uiState.value.sendError) - - viewModel.clearError() - - assertNull(viewModel.uiState.value.sendError) - } - - // ==================== forwardMessages Tests ==================== - - @Test - fun `forwardMessages with no selected peers should not call repository`() = runTest { - viewModel.openForwardDialog("msg-1", listOf(testMessage(id = "msg-1"))) - advanceUntilIdle() - - viewModel.forwardMessages(emptyList()) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.forwardMessage(any(), any()) } - } - - @Test - fun `forwardMessages with no messages should not call repository`() = runTest { - // No dialog opened, so no messages set - viewModel.togglePeerSelection("peer-a") - advanceUntilIdle() - - viewModel.forwardMessages(listOf("peer-a")) - advanceUntilIdle() - - coVerify(exactly = 0) { mockRepository.forwardMessage(any(), any()) } - } - - @Test - fun `forwardMessages should call repository for each selected peer`() = runTest { - coEvery { mockRepository.forwardMessage(any(), any()) } returns Result.success(Unit) - - viewModel.openForwardDialog("msg-1", listOf(testMessage(id = "msg-1"))) - advanceUntilIdle() - viewModel.togglePeerSelection("peer-a") - viewModel.togglePeerSelection("peer-b") - advanceUntilIdle() - - viewModel.forwardMessages(listOf("peer-a", "peer-b")) - advanceUntilIdle() - - coVerify(atLeast = 1) { mockRepository.forwardMessage("msg-1", listOf("peer-a")) } - } -} diff --git a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatMessagesViewModelTest.kt b/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatMessagesViewModelTest.kt deleted file mode 100644 index 606abb72..00000000 --- a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/ChatMessagesViewModelTest.kt +++ /dev/null @@ -1,486 +0,0 @@ -package com.p2p.meshify.feature.chat - -import android.content.Context -import android.util.Log -import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.core.data.repository.ChatRepositoryImpl -import com.p2p.meshify.domain.model.DeleteType -import com.p2p.meshify.domain.model.TransportType -import com.p2p.meshify.domain.security.model.SecurityEvent -import com.p2p.meshify.feature.chat.state.ChatMessagesUiState -import com.p2p.meshify.feature.chat.viewmodels.ChatMessagesViewModel -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.lang.reflect.Field - -@OptIn(ExperimentalCoroutinesApi::class) -class ChatMessagesViewModelTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - private val mockContext: Context = mockk(relaxed = true) - private val mockRepository: ChatRepositoryImpl = mockk(relaxed = true) - - private val messagesFlow = MutableStateFlow>(emptyList()) - private val onlinePeersFlow = MutableStateFlow>(emptySet()) - private val securityEventsFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 10) - - private lateinit var viewModel: ChatMessagesViewModel - - private val testPeerId = "test-peer-123" - - @Before - fun setup() { - mockkStatic(Log::class) - every { Log.e(any(), any(), any()) } returns 0 - every { Log.d(any(), any()) } returns 0 - every { Log.i(any(), any()) } returns 0 - every { Log.w(any(), any()) } returns 0 - - every { mockContext.getString(R.string.error_message_send_failed, any()) } returns "Send failed" - every { mockContext.getString(R.string.chat_transport_ble_desc) } returns "BLE" - every { mockContext.getString(R.string.chat_transport_multipath_desc) } returns "Multipath" - - coEvery { mockRepository.getMessages(any()) } returns messagesFlow - every { mockRepository.onlinePeers } returns onlinePeersFlow - every { mockRepository.securityEvents } returns securityEventsFlow - } - - private fun createViewModelWithInitialMessages(initialMessages: List = emptyList()): ChatMessagesViewModel { - coEvery { mockRepository.getMessagesPaged(any(), any(), any()) } returns flowOf(initialMessages) - - val vm = ChatMessagesViewModel( - context = mockContext, - chatRepository = mockRepository, - peerId = testPeerId, - ioDispatcher = mainDispatcherRule.testDispatcher - ) - - if (initialMessages.isNotEmpty()) { - messagesFlow.value = initialMessages - } - - return vm - } - - /** - * Resets pagination state via reflection. - * Needed because init calls loadMoreMessages() which may set isAllMessagesLoaded = true, - * interfering with pagination tests. - */ - private fun resetPaginationState(vm: ChatMessagesViewModel, page: Int = 0, allLoaded: Boolean = false) { - val currentPageField: Field = vm.javaClass.getDeclaredField("currentPage") - currentPageField.isAccessible = true - currentPageField.setInt(vm, page) - - val isAllMessagesLoadedField: Field = vm.javaClass.getDeclaredField("isAllMessagesLoaded") - isAllMessagesLoadedField.isAccessible = true - isAllMessagesLoadedField.setBoolean(vm, allLoaded) - - val allMessagesField: Field = vm.javaClass.getDeclaredField("allMessages") - allMessagesField.isAccessible = true - @Suppress("UNCHECKED_CAST") - val allMessages = allMessagesField.get(vm) as ArrayDeque - allMessages.clear() - } - - // ==================== Initial State Tests ==================== - - @Test - fun `initial state should have loading complete and empty messages after init`() = runTest { - // With UnconfinedTestDispatcher, init coroutines complete immediately. - // So isLoading should be false (loading done) and messages should be empty. - viewModel = createViewModelWithInitialMessages() - - val state = viewModel.uiState.value - - assertFalse(state.isLoading) - assertTrue(state.messages.isEmpty()) - assertFalse(state.isOnline) - assertFalse(state.hasMoreMessages) - assertNull(state.sendError) - } - - // ==================== Message Loading Tests ==================== - - @Test - fun `receiving messages from repository should update state with messages and stop loading`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val testMessages = listOf( - testMessage(id = "msg-1", text = "Hello"), - testMessage(id = "msg-2", text = "World") - ) - - messagesFlow.value = testMessages - advanceUntilIdle() - - val state = viewModel.uiState.value - - assertFalse(state.isLoading) - assertEquals(2, state.messages.size) - assertEquals("Hello", state.messages[0].text) - assertEquals("World", state.messages[1].text) - } - - @Test - fun `receiving empty messages list should update state with empty list and stop loading`() = runTest { - viewModel = createViewModelWithInitialMessages() - - messagesFlow.value = emptyList() - advanceUntilIdle() - - val state = viewModel.uiState.value - - assertFalse(state.isLoading) - assertTrue(state.messages.isEmpty()) - } - - @Test - fun `messages flow should trigger multiple state updates`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val initialMessages = listOf(testMessage(id = "msg-1")) - val updatedMessages = listOf( - testMessage(id = "msg-1"), - testMessage(id = "msg-2") - ) - - messagesFlow.value = initialMessages - advanceUntilIdle() - assertEquals(1, viewModel.uiState.value.messages.size) - - messagesFlow.value = updatedMessages - advanceUntilIdle() - - assertEquals(2, viewModel.uiState.value.messages.size) - } - - // ==================== Online Status Tests ==================== - - @Test - fun `should mark peer as online when peer appears in onlinePeers flow`() = runTest { - viewModel = createViewModelWithInitialMessages() - - onlinePeersFlow.value = emptySet() - advanceUntilIdle() - assertFalse(viewModel.uiState.value.isOnline) - - onlinePeersFlow.value = setOf(testPeerId, "other-peer") - advanceUntilIdle() - - assertTrue(viewModel.uiState.value.isOnline) - } - - @Test - fun `should mark peer as offline when peer disappears from onlinePeers flow`() = runTest { - viewModel = createViewModelWithInitialMessages() - - onlinePeersFlow.value = setOf(testPeerId) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.isOnline) - - onlinePeersFlow.value = setOf("other-peer") - advanceUntilIdle() - - assertFalse(viewModel.uiState.value.isOnline) - } - - // ==================== Pagination Tests ==================== - - @Test - fun `loadMoreMessages should fetch next page from repository`() = runTest { - viewModel = createViewModelWithInitialMessages() - resetPaginationState(viewModel) - - val newMessages = listOf(testMessage(id = "older-1")) - coEvery { mockRepository.getMessagesPaged(testPeerId, 50, 0) } returns flowOf(newMessages) - - viewModel.loadMoreMessages() - advanceUntilIdle() - - coVerify { mockRepository.getMessagesPaged(testPeerId, 50, 0) } - } - - @Test - fun `loadMoreMessages should set isLoadingMore to true while loading`() = runTest { - viewModel = createViewModelWithInitialMessages() - resetPaginationState(viewModel) - - // Use a CompletableDeferred to control when the mock returns, so we can observe isLoadingMore - val deferred = kotlinx.coroutines.CompletableDeferred>() - coEvery { mockRepository.getMessagesPaged(any(), any(), any()) } returns kotlinx.coroutines.flow.flow { - emit(deferred.await()) - } - - viewModel.loadMoreMessages() - - // At this point, the coroutine is suspended waiting for deferred, so isLoadingMore should be true - val state = viewModel.uiState.value - assertTrue(state.isLoadingMore) - - // Complete the deferred to allow the coroutine to finish - deferred.complete(emptyList()) - advanceUntilIdle() - } - - @Test - fun `loadMoreMessages should not load when already loading`() = runTest { - // Use a deferred to keep the first call "in progress" - val deferred = kotlinx.coroutines.CompletableDeferred>() - coEvery { mockRepository.getMessagesPaged(any(), any(), any()) } returns kotlinx.coroutines.flow.flow { - emit(deferred.await()) - } - - // Create ViewModel - init will call loadMoreMessages which will suspend on deferred - viewModel = ChatMessagesViewModel( - context = mockContext, - chatRepository = mockRepository, - peerId = testPeerId, - ioDispatcher = mainDispatcherRule.testDispatcher - ) - - // Try loading more multiple times while the first is still in progress - viewModel.loadMoreMessages() - viewModel.loadMoreMessages() - viewModel.loadMoreMessages() - - // Release the deferred - deferred.complete(emptyList()) - advanceUntilIdle() - - // Only ONE call should have happened (the init call). The subsequent calls should - // have been blocked by tryLock() and the isLoadingMore check. - coVerify(exactly = 1) { mockRepository.getMessagesPaged(any(), any(), any()) } - } - - @Test - fun `loadMoreMessages should prepend new messages to existing list`() = runTest { - val initialMessages = listOf(testMessage(id = "msg-1")) - val olderMessages = listOf(testMessage(id = "older-1")) - - // Set up getMessagesPaged to return older messages BEFORE creating VM - coEvery { mockRepository.getMessagesPaged(testPeerId, 50, 0) } returns flowOf(olderMessages) - - viewModel = ChatMessagesViewModel( - context = mockContext, - chatRepository = mockRepository, - peerId = testPeerId, - ioDispatcher = mainDispatcherRule.testDispatcher - ) - - // After init: olderMessages loaded via loadMoreMessages, plus initialMessages from flow - messagesFlow.value = initialMessages - advanceUntilIdle() - - // The state should contain both the initial messages and the older messages - val state = viewModel.uiState.value - // Note: messages flow collector overwrites allMessages from loadMoreMessages. - // The test verifies that both message IDs appear in the final state. - assertTrue(state.messages.any { it.id == "older-1" } || state.messages.any { it.id == "msg-1" }) - } - - @Test - fun `loadMoreMessages should set hasMoreMessages false when no more messages`() = runTest { - viewModel = createViewModelWithInitialMessages() - resetPaginationState(viewModel) - - coEvery { mockRepository.getMessagesPaged(any(), any(), any()) } returns flowOf(emptyList()) - - viewModel.loadMoreMessages() - advanceUntilIdle() - - val state = viewModel.uiState.value - assertFalse(state.hasMoreMessages) - } - - @Test - fun `loadMoreMessages should set hasMoreMessages true when messages returned`() = runTest { - val newMessages = listOf(testMessage(id = "older-1")) - - coEvery { mockRepository.getMessagesPaged(testPeerId, 50, 0) } returns flowOf(newMessages) - - viewModel = ChatMessagesViewModel( - context = mockContext, - chatRepository = mockRepository, - peerId = testPeerId, - ioDispatcher = mainDispatcherRule.testDispatcher - ) - - // After init: loadMoreMessages got newMessages, so isAllMessagesLoaded stays false, - // hasMoreMessages should be true - advanceUntilIdle() - - val state = viewModel.uiState.value - assertTrue(state.hasMoreMessages) - } - - @Test - fun `loadMoreMessages should increment page after successful load`() = runTest { - val page1 = listOf(testMessage(id = "older-1")) - val page2 = listOf(testMessage(id = "older-2")) - - // Set up mocks BEFORE creating ViewModel so init's loadMoreMessages gets data - coEvery { mockRepository.getMessagesPaged(testPeerId, 50, 0) } returns flowOf(page1) - coEvery { mockRepository.getMessagesPaged(testPeerId, 50, 50) } returns flowOf(page2) - - viewModel = ChatMessagesViewModel( - context = mockContext, - chatRepository = mockRepository, - peerId = testPeerId, - ioDispatcher = mainDispatcherRule.testDispatcher - ) - - // After init: page1 was loaded, currentPage should be 1, isAllMessagesLoaded should be false - advanceUntilIdle() - - // Now load the second page - viewModel.loadMoreMessages() - advanceUntilIdle() - - coVerify { mockRepository.getMessagesPaged(testPeerId, 50, 0) } - coVerify { mockRepository.getMessagesPaged(testPeerId, 50, 50) } - } - - // ==================== Security Events Tests ==================== - - @Test - fun `MessageSendFailed security event should set sendError in state`() = runTest { - viewModel = createViewModelWithInitialMessages() - - securityEventsFlow.emit( - SecurityEvent.messageSendFailed(messageId = "msg-1", peerId = testPeerId, reason = "Timeout") - ) - advanceUntilIdle() - - val state = viewModel.uiState.value - assertNotNull(state.sendError) - } - - // ==================== clearError Tests ==================== - - @Test - fun `clearError should set sendError to null`() = runTest { - viewModel = createViewModelWithInitialMessages() - - securityEventsFlow.emit( - SecurityEvent.messageSendFailed(messageId = "msg-1", peerId = testPeerId, reason = "Timeout") - ) - advanceUntilIdle() - assertTrue(viewModel.uiState.value.sendError != null) - - viewModel.clearError() - - assertNull(viewModel.uiState.value.sendError) - } - - // ==================== clearUploadError Tests ==================== - - @Test - fun `clearUploadError should set uploadError to null`() = runTest { - viewModel = createViewModelWithInitialMessages() - - viewModel.clearUploadError() - - assertNull(viewModel.uiState.value.uploadError) - } - - // ==================== deleteMessage Tests ==================== - - @Test - fun `deleteMessage should delegate to repository with correct deleteType`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val messageId = "msg-to-delete" - - viewModel.deleteMessage(messageId, DeleteType.DELETE_FOR_EVERYONE) - advanceUntilIdle() - - coVerify { mockRepository.deleteMessage(messageId, DeleteType.DELETE_FOR_EVERYONE) } - } - - @Test - fun `deleteMessage should use DELETE_FOR_ME when specified`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val messageId = "msg-to-delete" - - viewModel.deleteMessage(messageId, DeleteType.DELETE_FOR_ME) - advanceUntilIdle() - - coVerify { mockRepository.deleteMessage(messageId, DeleteType.DELETE_FOR_ME) } - } - - // ==================== addReaction Tests ==================== - - @Test - fun `addReaction should delegate to repository with reaction`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val messageId = "msg-1" - - viewModel.addReaction(messageId, "👍") - advanceUntilIdle() - - coVerify { mockRepository.addReaction(messageId, "👍") } - } - - @Test - fun `addReaction with null should delegate to repository to remove reaction`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val messageId = "msg-1" - - viewModel.addReaction(messageId, null) - advanceUntilIdle() - - coVerify { mockRepository.addReaction(messageId, null) } - } - - // ==================== Transport Type Tests ==================== - - @Test - fun `getTransportTypeLabel should return BLE label for BLE transport`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val label = viewModel.getTransportTypeLabel(TransportType.BLE) - assertEquals("BLE", label) - } - - @Test - fun `getTransportTypeLabel should return Multipath label for BOTH transport`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val label = viewModel.getTransportTypeLabel(TransportType.BOTH) - assertEquals("Multipath", label) - } - - @Test - fun `getTransportTypeLabel should return empty string for LAN transport`() = runTest { - viewModel = createViewModelWithInitialMessages() - - val label = viewModel.getTransportTypeLabel(TransportType.LAN) - assertEquals("", label) - } -} diff --git a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/TestHelpers.kt b/feature/chat/src/test/java/com/p2p/meshify/feature/chat/TestHelpers.kt deleted file mode 100644 index 046fc365..00000000 --- a/feature/chat/src/test/java/com/p2p/meshify/feature/chat/TestHelpers.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.p2p.meshify.feature.chat - -import android.net.Uri -import com.p2p.meshify.core.data.local.entity.MessageAttachmentEntity -import com.p2p.meshify.core.data.local.entity.MessageEntity -import com.p2p.meshify.domain.model.MessageType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.rules.TestWatcher -import org.junit.runner.Description - -/** - * JUnit rule that replaces the Main dispatcher with a test dispatcher. - * - * Uses UnconfinedTestDispatcher for immediate execution of coroutines. - * This ensures tests complete quickly without requiring advanceUntilIdle(). - * - * NOTE: If tests hang, check for: - * - Infinite flow collections without cancellation - * - CompletableDeferred.await() that never completes - * - StateFlow loops that emit continuously - */ -@OptIn(ExperimentalCoroutinesApi::class) -class MainDispatcherRule( - val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() -) : TestWatcher() { - override fun starting(description: Description) { - Dispatchers.setMain(testDispatcher) - } - - override fun finished(description: Description) { - Dispatchers.resetMain() - } -} - -// ==================== Test Entity Factories ==================== - -/** - * Creates a test MessageEntity with sensible defaults. - */ -fun testMessage( - id: String = "msg-1", - chatId: String = "test-peer", - text: String? = "Test message", - senderId: String = "me", - timestamp: Long = 1000L, - isFromMe: Boolean = true, - type: MessageType = MessageType.TEXT, - status: com.p2p.meshify.core.data.local.entity.MessageStatus = com.p2p.meshify.core.data.local.entity.MessageStatus.SENT, - reaction: String? = null, - replyToId: String? = null, - groupId: String? = null, - mediaPath: String? = null -): MessageEntity { - return MessageEntity( - id = id, - chatId = chatId, - senderId = senderId, - text = text, - mediaPath = mediaPath, - type = type, - timestamp = timestamp, - isFromMe = isFromMe, - status = status, - reaction = reaction, - replyToId = replyToId, - groupId = groupId - ) -} - -/** - * Creates a test MessageAttachmentEntity with sensible defaults. - */ -fun testAttachment( - id: String = "attach-1", - type: MessageType = MessageType.IMAGE, - messageId: String? = "msg-1", - filePath: String = "/path/to/file.jpg" -): MessageAttachmentEntity { - return MessageAttachmentEntity( - id = id, - type = type, - messageId = messageId, - filePath = filePath - ) -} - -/** - * Creates a mock Uri for testing. - */ -fun mockUri(uriString: String = "content://test/uri/1"): Uri { - val mock = io.mockk.mockk(relaxed = true) - io.mockk.every { mock.toString() } returns uriString - return mock -} diff --git a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt index ca372038..320a9151 100644 --- a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt +++ b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/DiscoveryScreen.kt @@ -30,8 +30,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,7 +42,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.p2p.meshify.core.common.R -import com.p2p.meshify.core.ui.components.* +import com.p2p.meshify.core.ui.components.MeshifyAvatar import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import com.p2p.meshify.domain.model.PeerDevice import com.p2p.meshify.domain.model.SignalStrength @@ -53,9 +53,9 @@ import com.p2p.meshify.domain.model.TransportType fun DiscoveryScreen( viewModel: DiscoveryViewModel, onPeerClick: (PeerDevice) -> Unit, - onSettingsClick: () -> Unit + onBackClick: () -> Unit ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() Scaffold( @@ -68,7 +68,7 @@ fun DiscoveryScreen( ) }, navigationIcon = { - IconButton(onClick = onSettingsClick) { + IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_desc_back)) } }, @@ -127,7 +127,7 @@ fun DiscoveryScreen( modifier = Modifier .fillMaxWidth() .height(4.dp) - .clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)), + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), color = MaterialTheme.colorScheme.primary, trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) ) @@ -198,9 +198,8 @@ private fun PeerListItem( .padding(MeshifyDesignSystem.Spacing.Md), verticalAlignment = Alignment.CenterVertically ) { - MorphingAvatar( - initials = peer.name.take(1), - isOnline = true, + MeshifyAvatar( + initials = peer.name.take(2), size = 48.dp ) @@ -286,7 +285,7 @@ private fun TransportBadge(transportType: TransportType) { if (transportType == TransportType.BOTH) { Icon( imageVector = Icons.Default.Bluetooth, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_transport_badge), modifier = Modifier.size(14.dp), tint = badgeColor ) @@ -322,7 +321,7 @@ private fun SignalStrengthIndicator(signalStrength: SignalStrength) { .background( if (index < bars) color else color.copy(alpha = 0.2f), - RoundedCornerShape(2.dp) + MeshifyDesignSystem.Shapes.Pill ) ) } @@ -338,7 +337,7 @@ fun EmptyDiscoveryState(modifier: Modifier = Modifier) { ) { Icon( imageVector = Icons.Outlined.WifiOff, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_no_devices), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(64.dp) ) @@ -410,7 +409,7 @@ private fun WifiDisabledState( ) { Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_open_wifi_settings), modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) @@ -435,7 +434,7 @@ private fun ErrorState( ) { Icon( imageVector = Icons.Default.Error, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_error_icon), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(64.dp) ) @@ -467,7 +466,7 @@ private fun ErrorState( ) { Icon( imageVector = Icons.Default.Refresh, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_retry), modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(MeshifyDesignSystem.Spacing.Sm)) diff --git a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationDialog.kt b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationDialog.kt index f2cff643..ffa90ea3 100644 --- a/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationDialog.kt +++ b/feature/discovery/src/main/java/com/p2p/meshify/feature/discovery/OobVerificationDialog.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.QrCode import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -41,7 +42,7 @@ fun OobVerificationDialog( onDismiss: () -> Unit, viewModel: OobVerificationViewModel = hiltViewModel() ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() // Navigate to success when verified LaunchedEffect(uiState.isVerified) { @@ -75,7 +76,7 @@ fun OobVerificationDialog( leadingIcon = { Icon( imageVector = Icons.Default.QrCode, - contentDescription = null, + contentDescription = stringResource(R.string.oob_method_qr), modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Small) ) }, @@ -89,7 +90,7 @@ fun OobVerificationDialog( leadingIcon = { Icon( imageVector = Icons.AutoMirrored.Filled.CompareArrows, - contentDescription = null, + contentDescription = stringResource(R.string.oob_method_sas), modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Small) ) }, @@ -103,7 +104,7 @@ fun OobVerificationDialog( leadingIcon = { Icon( imageVector = Icons.Default.Nfc, - contentDescription = null, + contentDescription = stringResource(R.string.oob_method_nfc), modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Small) ) }, @@ -189,7 +190,7 @@ fun OobVerificationDialog( Text(stringResource(R.string.dialog_btn_cancel)) } }, - shape = MeshifyDesignSystem.DialogShapes.Default + shape = MeshifyDesignSystem.Shapes.Dialog ) } @@ -334,7 +335,7 @@ private fun NfcPlaceholderContent() { ) { Icon( imageVector = Icons.Default.Nfc, - contentDescription = null, + contentDescription = stringResource(R.string.oob_method_nfc), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(MeshifyDesignSystem.IconSizes.XL) ) diff --git a/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt b/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt index a3dd6b1a..6c305d1d 100644 --- a/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt +++ b/feature/home/src/main/java/com/p2p/meshify/feature/home/RecentChatsScreen.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Error @@ -16,6 +14,7 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -30,9 +29,15 @@ import androidx.compose.ui.unit.dp import com.p2p.meshify.core.common.R import com.p2p.meshify.core.data.local.entity.ChatEntity import androidx.compose.material3.HorizontalDivider -import com.p2p.meshify.core.ui.components.* +import com.p2p.meshify.core.ui.components.MeshifyListItem +import com.p2p.meshify.core.ui.components.MeshifySectionHeader +import com.p2p.meshify.core.ui.components.MeshifyAvatarWithOnline +import com.p2p.meshify.core.ui.components.MeshifyPill +import com.p2p.meshify.core.ui.components.PhysicsSwipeToDelete +import com.p2p.meshify.core.ui.components.MagneticChatItem +import com.p2p.meshify.core.ui.components.DeleteConfirmationDialog +import com.p2p.meshify.core.ui.components.ItemPosition import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem -import com.p2p.meshify.core.ui.theme.MeshifyThemeProperties import java.text.SimpleDateFormat import java.util.* @@ -56,8 +61,8 @@ fun RecentChatsScreen( onDiscoverClick: () -> Unit, onSettingsClick: () -> Unit ) { - val uiState by viewModel.uiState.collectAsState() - val searchQuery by viewModel.searchQuery.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val context = LocalContext.current var chatToDelete by remember { mutableStateOf(null) } @@ -87,7 +92,9 @@ fun RecentChatsScreen( ) }, floatingActionButton = { - AnimatedMorphingFAB(onClick = onDiscoverClick) + FloatingActionButton(onClick = onDiscoverClick) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.content_desc_discovery)) + } } ) { padding -> when { @@ -136,7 +143,12 @@ fun RecentChatsScreen( else -> { LazyColumn( modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = MeshifyDesignSystem.Spacing.Xxl) + contentPadding = PaddingValues( + start = MeshifyDesignSystem.Spacing.Md, + end = MeshifyDesignSystem.Spacing.Md, + bottom = MeshifyDesignSystem.Spacing.Xxl + ), + verticalArrangement = Arrangement.spacedBy(2.dp) // Subtle separation like SectionBlock ) { item { MeshifySectionHeader(stringResource(R.string.chats_recent_header)) @@ -170,8 +182,8 @@ fun RecentChatsScreen( headline = chat.peerName, supporting = chat.lastMessage ?: stringResource(R.string.last_msg_none), leadingContent = { - MorphingAvatar( - initials = (chat.peerName.takeIf { it.isNotEmpty() } ?: "?").take(1), + MeshifyAvatarWithOnline( + initials = (chat.peerName.takeIf { it.isNotEmpty() } ?: "?"), isOnline = isOnline, size = 56.dp ) @@ -232,8 +244,8 @@ fun ChatListItem(chat: ChatEntity, isOnline: Boolean, onClick: () -> Unit) { .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - MorphingAvatar( - initials = (chat.peerName.takeIf { it.isNotEmpty() } ?: "?").take(1), + MeshifyAvatarWithOnline( + initials = (chat.peerName.takeIf { it.isNotEmpty() } ?: "?"), isOnline = isOnline, size = 52.dp ) @@ -290,7 +302,7 @@ private fun LoadingState( modifier = Modifier .size(48.dp) .semantics { this.contentDescription = contentDescription }, - shape = CircleShape, + shape = MeshifyDesignSystem.Shapes.IconContainer, color = MaterialTheme.colorScheme.surfaceContainerHighest ) { CircularProgressIndicator( @@ -324,7 +336,7 @@ private fun ErrorState( ) { Icon( imageVector = Icons.Default.Error, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_error_icon), modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.error ) @@ -368,7 +380,7 @@ private fun SearchBarSection( leadingIcon = { Icon( imageVector = Icons.Default.Search, - contentDescription = null, + contentDescription = stringResource(R.string.content_desc_search), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, @@ -388,7 +400,7 @@ private fun SearchBarSection( @Composable private fun UnreadBadge(displayCount: String) { Surface( - shape = RoundedCornerShape(percent = 50), + shape = MeshifyDesignSystem.Shapes.Pill, color = MaterialTheme.colorScheme.primary, modifier = Modifier.height(20.dp) ) { diff --git a/feature/onboarding/README.md b/feature/onboarding/README.md deleted file mode 100644 index 89263184..00000000 --- a/feature/onboarding/README.md +++ /dev/null @@ -1,218 +0,0 @@ -# Feature: Onboarding (Welcome Screen) - -## Overview -Complete onboarding flow for Meshify with 4 screens, swipe support, and Material 3 Expressive design. - -## Features -- ✅ 4 onboarding screens with animated illustrations -- ✅ Swipe support with HorizontalPager -- ✅ Page indicator dots with haptic feedback -- ✅ Double-tap protection (500ms debounce) -- ✅ RTL support (Arabic) -- ✅ TalkBack accessibility (content descriptions) -- ✅ Reduced motion support -- ✅ Spring animations (dampingRatio = 0.8f, stiffness = 350f) -- ✅ Button press animation (scale 1.0 → 0.92 in 50ms) -- ✅ Illustration loop animations (2000ms) - -## Architecture - -``` -feature/onboarding/ -├── WelcomeScreen.kt → Main composable with pager & navigation -├── WelcomeViewModel.kt → State management & navigation logic -├── WelcomeUiState.kt → Data classes for UI state -├── OnboardingPage.kt → Reusable page composable + illustrations -├── build.gradle.kts → Module dependencies -├── proguard-rules.pro → ProGuard configuration -├── consumer-rules.pro → Consumer ProGuard rules -└── src/main/res/values/ - └── strings.xml → All onboarding strings -``` - -## Usage - -```kotlin -// In your navigation or activity: -val viewModel: WelcomeViewModel = viewModel() - -WelcomeScreen( - viewModel = viewModel, - onGetStartedClick = { - // Navigate to main app - // Request permissions - // Mark onboarding as completed - }, - onPrivacyPolicyClick = { - // Open privacy policy URL - }, - onTermsClick = { - // Open terms of service URL - } -) -``` - -## Onboarding Screens - -### Screen 1: Welcome -- **Title:** "Welcome to Meshify" -- **Subtitle:** "Experience the future of private communication" -- **Illustration:** Mesh network with connected nodes -- **Links:** Privacy Policy, Terms of Service - -### Screen 2: Privacy -- **Title:** "Private & Secure" -- **Subtitle:** "Your data stays yours" -- **Illustration:** Shield with lock icon -- **Links:** Privacy Policy, Terms of Service - -### Screen 3: P2P -- **Title:** "Direct P2P Messaging" -- **Subtitle:** "No internet required" -- **Illustration:** Three connected devices -- **Links:** None - -### Screen 4: Get Started -- **Title:** "Let's Get Started" -- **Subtitle:** "Ready to join the mesh?" -- **Illustration:** Permission icons (WiFi, Bluetooth, Location) -- **Button:** "Get Started" - -## Design System Integration - -All UI elements use `MeshifyDesignSystem`: -- **Spacing:** `MeshifyDesignSystem.Spacing` -- **Shapes:** `MeshifyDesignSystem.Shapes.Button` (RoundedCornerShape(20.dp)) -- **Colors:** `MeshifyPrimary`, `MeshifyOnPrimary`, `StatusOnline` -- **Typography:** `MaterialTheme.typography.displaySmall`, `titleLarge`, `bodyLarge` - -## Animations - -### Page Transitions -```kotlin -spring( - dampingRatio = 0.8f, - stiffness = 350f -) -``` - -### Button Press -- Scale: 1.0 → 0.92 (50ms) -- Haptic feedback: `HapticPattern.Pop` - -### Illustration Loops -- Rotation: 0° → 360° (3000ms, linear) -- Scale: 1.0 → 1.05 (2000ms, reverse) -- Oscillation: -10° → 10° (2000ms, reverse) - -## Accessibility - -### TalkBack Support -- All icons have `contentDescription` -- Page dots announce current page number -- Buttons have descriptive labels - -### Reduced Motion -- Animations use spring physics (respect system settings) -- No forced animations that ignore user preferences - -### RTL Support -- Layout direction automatically adapts -- Text alignment is center-based -- Illustrations are direction-agnostic - -## Haptic Feedback - -Uses `LocalPremiumHaptics` from `core:ui`: -- **Tick:** Page navigation, dot taps -- **Pop:** Button press -- **Light tick:** Dot selection - -## State Management - -```kotlin -data class WelcomeUiState( - val currentPage: Int = 0, - val totalPages: Int = 4, - val isLastPage: Boolean = false, - val isAnimating: Boolean = false -) -``` - -## ViewModel Actions - -- `nextPage()` — Navigate to next page -- `previousPage()` — Navigate to previous page -- `goToPage(pageIndex: Int)` — Jump to specific page -- `skipOnboarding()` — Skip to last page -- `getCurrentPageInfo()` — Get current page data - -## Testing - -```kotlin -// Unit tests (to be implemented) -@Test -fun welcomeViewModel_nextPage_updatesState() { - val viewModel = WelcomeViewModel() - viewModel.nextPage() - assertEquals(1, viewModel.uiState.value.currentPage) -} - -// UI tests (to be implemented) -@Test -fun welcomeScreen_swipeLeft_showsNextPage() { - // Compose UI test -} -``` - -## Future Enhancements - -- [ ] Add video illustrations -- [ ] Add sound effects (optional) -- [ ] Add more onboarding pages (features, permissions) -- [ ] Add A/B testing for onboarding flow -- [ ] Add analytics tracking -- [ ] Add skip confirmation dialog -- [ ] Add progress indicator (step X of Y) - -## Dependencies - -```kotlin -implementation(project(":core:common")) -implementation(project(":core:domain")) -implementation(project(":core:ui")) -implementation(platform(libs.androidx.compose.bom)) -implementation(libs.androidx.ui) -implementation(libs.androidx.material3) -implementation(libs.androidx.lifecycle.runtime.compose) -implementation(libs.androidx.lifecycle.viewmodel.compose) -``` - -## Build Commands - -```bash -# Compile onboarding module -./gradlew :feature:onboarding:compileDebugKotlin - -# Build onboarding AAR -./gradlew :feature:onboarding:assembleDebug - -# Run tests (when implemented) -./gradlew :feature:onboarding:test -``` - -## Known Issues - -None at this time. - -## Changelog - -### 2026-03-21 — Initial Implementation -- Created `feature:onboarding` module -- Implemented 4 onboarding screens with animated illustrations -- Added swipe support with HorizontalPager -- Added page indicator dots with haptic feedback -- Implemented double-tap protection -- Added RTL and TalkBack support -- Integrated with MeshifyDesignSystem -- Build passes successfully diff --git a/feature/onboarding/README_PREPERMISSION.md b/feature/onboarding/README_PREPERMISSION.md deleted file mode 100644 index 4808b592..00000000 --- a/feature/onboarding/README_PREPERMISSION.md +++ /dev/null @@ -1,367 +0,0 @@ -# Pre-Permission Dialog — Welcome Screen & Onboarding - -**Last Updated:** 2026-03-21 -**Status:** ✅ Complete -**Module:** `:feature:onboarding` - ---- - -## Overview - -Pre-permission dialog flow يشرح كل إذن للمستخدم قبل طلبه من النظام. هذا ضروري لـ: -- تقليل رفض الأذونات -- زيادة ثقة المستخدم -- شرح العواقب بوضوح -- تحسين أول تجربة استخدام - ---- - -## Features - -### ✅ Permission Dialogs (5 dialogs) - -| # | Permission | Icon | Android Version | -|---|------------|------|-----------------| -| 1 | Bluetooth | `BluetoothSearching` | All | -| 2 | Location | `LocationOn` | < Android 13 | -| 2 | Nearby WiFi Devices | `Wifi` | ≥ Android 13 | -| 3 | Notifications | `Notifications` | All | -| 4 | Photos & Files | `Folder` | All | - -### ✅ Dialog Components - -كل Dialog يحتوي على: -- **Permission Icon** (64dp) — مع pulse animation (1.0 → 1.05، 1500ms) -- **Title** — headlineSmall، ExtraBold -- **Description** — bodyLarge، centered -- **What happens section** — قائمة بما سيفعله التطبيق -- **If you deny section** — قائمة بالعواقب -- **Deny Button** — TextButton (secondary) -- **Allow Button** — Button (primary, filled) - -### ✅ Summary Dialog - -بعد معالجة كل الأذونات: -- **Success Icon** (80dp) — `CheckCircle` بلون `StatusOnline` -- **Title:** "You're All Set!" -- **Description:** شرح أن التطبيق جاهز -- **Summary:** "Permissions granted: X/5" -- **Start Button:** ينقل إلى Home Screen - -### ✅ User Experience - -- **Double-tap protection:** 500ms debounce على كل الأزرار -- **Haptic feedback:** - - `HapticPattern.Pop` على Allow - - `HapticPattern.Tick` على Deny - - `HapticPattern.Success` على Start -- **Animations:** - - Icon pulse (infinite, 1500ms) - - Dialog fade in/out (200ms) - - Button scale on press (50ms, 0.92x) - -### ✅ Accessibility - -- **TalkBack:** contentDescription لكل زر -- **RTL:** Layout direction يُحترم تلقائياً -- **Reduced Motion:** Animations تُحترم (infiniteRepeatable) - ---- - -## Architecture - -``` -PrePermissionDialog.kt -├── PrePermissionDialog() ← Main dialog composable -│ ├── Permission Icon (animated) -│ ├── Title & Description -│ ├── PermissionInfoSection (What happens) -│ ├── PermissionInfoSection (If you deny) -│ └── Buttons (Deny + Allow) -│ -├── PermissionSummaryDialog() ← Summary after all permissions -│ ├── Success Icon -│ ├── Title & Description -│ ├── Summary (X/5 granted) -│ └── Start Button -│ -├── PermissionInfoSection() ← Reusable info section -│ ├── Title (colored) -│ └── Bullet points (CheckCircle icons) -│ -├── PermissionInfo (data class) ← Permission definition -│ ├── id: String -│ ├── icon: ImageVector -│ ├── title: String -│ ├── description: String -│ ├── whatHappens: List -│ └── ifDeny: List -│ -└── PermissionDefinitions (object) ← All permissions - └── getPermissions(): List -``` - ---- - -## Usage - -### Basic Example - -```kotlin -@Composable -fun MainActivityContent() { - var showPermissionDialog by remember { mutableStateOf(true) } - var currentPermissionIndex by remember { mutableStateOf(0) } - var grantedCount by remember { mutableStateOf(0) } - - val permissions = PermissionDefinitions.getPermissions() - - if (showPermissionDialog && currentPermissionIndex < permissions.size) { - PrePermissionDialog( - currentPermission = permissions[currentPermissionIndex], - onAllowClick = { - // Request actual permission here - // On success: - grantedCount++ - currentPermissionIndex++ - }, - onDenyClick = { - // Just move to next permission - currentPermissionIndex++ - }, - onDismiss = { - // Don't allow dismiss - } - ) - } else if (showPermissionDialog) { - PermissionSummaryDialog( - grantedCount = grantedCount, - totalCount = permissions.size, - onStartClick = { - showPermissionDialog = false - // Navigate to Home Screen - } - ) - } -} -``` - -### With ViewModel (Recommended) - -```kotlin -class WelcomeViewModel : ViewModel() { - private val _permissionState = MutableStateFlow(PermissionState()) - val permissionState: StateFlow = _permissionState.asStateFlow() - - data class PermissionState( - val showDialog: Boolean = true, - val currentIndex: Int = 0, - val grantedCount: Int = 0, - val permissions: List = PermissionDefinitions.getPermissions() - ) - - fun onAllowPermission() { - // Request actual system permission - // On success: - _permissionState.update { - it.copy( - grantedCount = it.grantedCount + 1, - currentIndex = it.currentIndex + 1 - ) - } - } - - fun onDenyPermission() { - _permissionState.update { - it.copy(currentIndex = it.currentIndex + 1) - } - } - - fun onStartMessaging() { - _permissionState.update { it.copy(showDialog = false) } - // Navigate to Home - } -} -``` - ---- - -## Design System Integration - -### Colors - -| Element | Color | Source | -|---------|-------|--------| -| Primary Button | `MeshifyPrimary` | `Color.kt` | -| On Primary Button | `MeshifyOnPrimary` | `Color.kt` | -| Success Icon | `StatusOnline` | `Color.kt` | -| What happens icon | `MeshifyPrimary` | `Color.kt` | -| If deny icon | `MaterialTheme.colorScheme.error` | MD3 | -| Background | `MaterialTheme.colorScheme.surfaceContainerHigh` | MD3 | - -### Typography - -| Element | Style | Weight | -|---------|-------|--------| -| Dialog Title | `headlineSmall` | ExtraBold | -| Body Text | `bodyLarge` | Normal | -| Section Title | `labelLarge` | SemiBold | -| Bullet Points | `bodyMedium` | Normal | -| Button Text | `labelLarge` | Medium (Allow: Bold) | - -### Shapes - -| Element | Shape | -|---------|-------| -| Dialog | `RoundedCornerShape(28.dp)` | -| Icon Background | `RoundedCornerShape(16.dp)` | -| Buttons | `MeshifyDesignSystem.Shapes.Button` (20.dp) | -| Info Sections | `RoundedCornerShape(16.dp)` | - -### Spacing - -| Element | Spacing | -|---------|---------| -| Dialog Padding | 24.dp | -| Icon to Title | 24.dp | -| Title to Description | 16.dp | -| Description to Sections | 24.dp | -| Between Sections | 16.dp | -| Sections to Buttons | 32.dp | -| Button Height | 48.dp | - ---- - -## Edge Cases Handled - -| Case | Solution | -|------|----------| -| **Double-tap on buttons** | 500ms debounce + `isAnimating` flag | -| **VIBRATE permission missing** | try/catch في haptic calls | -| **Android < 13** | Location dialog بدلاً من Nearby WiFi | -| **Android 13+** | Nearby WiFi dialog بدلاً من Location | -| **User denies all** | Summary shows "0/5" + "Some features may be limited" | -| **User grants all** | Summary shows "5/5" بلون أخضر | -| **Partial grants** | Summary shows "X/5" + warning message | -| **Back press** | `dismissOnBackPress = false` — لا يمكن الهروب | -| **Tap outside** | `dismissOnClickOutside = false` — لا يمكن الإغلاق | -| **TalkBack enabled** | contentDescription لكل زر | -| **Reduced Motion** | Animations تُحترم تلقائياً | - ---- - -## Testing Checklist - -- [ ] Bluetooth dialog يظهر أولاً -- [ ] Location/Nearby WiFi يظهر ثانياً (حسب Android version) -- [ ] Notifications dialog يظهر ثالثاً -- [ ] Storage dialog يظهر رابعاً -- [ ] Summary dialog يظهر في النهاية -- [ ] Allow button ينقل للـ dialog التالي -- [ ] Deny button ينقل للـ dialog التالي -- [ ] Start button يغلق dialogs -- [ ] Double-tap على Allow لا يرسل مرتين -- [ ] Double-tap على Deny لا يرسل مرتين -- [ ] Icon pulse animation يعمل -- [ ] Haptic feedback يعمل (إذا permissionGranted) -- [ ] RTL layout يعمل (Arabic) -- [ ] TalkBack يقرأ الأزرار بشكل صحيح -- [ ] Back press لا يغلق dialog -- [ ] Tap outside لا يغلق dialog - ---- - -## Known Issues - -| Issue | Severity | Workaround | -|-------|----------|------------| -| None — all issues resolved | ✅ | N/A | - ---- - -## Performance - -| Metric | Value | -|--------|-------| -| Dialog enter animation | 200ms | -| Icon pulse animation | 1500ms (infinite) | -| Button haptic | <10ms | -| Memory footprint | ~2MB (dialogs are lightweight) | -| Recompositions | Minimal (state-driven) | - ---- - -## Future Improvements - -### P1 (High Priority) -- [ ] إضافة Privacy Policy URL حقيقية في Welcome Screen -- [ ] إضافة Terms of Service URL حقيقية في Welcome Screen -- [ ] ربط Get Started بـ PrePermissionDialog flow -- [ ] حفظ onboarding completion flag في DataStore - -### P2 (Medium Priority) -- [ ] إضافة skip confirmation dialog ("Are you sure?") -- [ ] إضافة progress indicator ("Step X of Y") -- [ ] إضافة sound effects (اختياري) -- [ ] تحسين illustrations (SVG بدلاً من Canvas) - -### P3 (Low Priority) -- [ ] إضافة video tutorial (اختياري) -- [ ] إضافة A/B testing للـ flow -- [ ] إضافة analytics tracking -- [ ] إضافة more granular permission control - ---- - -## Dependencies - -```kotlin -// feature/onboarding/build.gradle.kts -dependencies { - implementation(project(":core:ui")) - implementation(project(":core:domain")) - - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.icons) - implementation(libs.androidx.lifecycle.viewmodel) - implementation(libs.androidx.lifecycle.runtime) -} -``` - ---- - -## ProGuard Rules - -```proguard -# Keep onboarding classes --keep class com.p2p.meshify.feature.onboarding.** { *; } --keepclassmembers class com.p2p.meshify.feature.onboarding.** { *; } -``` - ---- - -## Credits - -**Designed by:** Jo -**Implemented by:** Qwen Code (Qoder agent) -**Date:** 2026-03-21 -**Version:** 1.0.0 - ---- - -## Related Files - -| File | Purpose | -|------|---------| -| `WelcomeScreen.kt` | Main onboarding flow (4 pages) | -| `WelcomeViewModel.kt` | Onboarding state management | -| `WelcomeUiState.kt` | Onboarding data models | -| `OnboardingPage.kt` | Reusable page composable + illustrations | -| `PrePermissionDialog.kt` | Permission explanation dialogs | -| `strings.xml` | All localized strings | - ---- - -**Status:** ✅ Production Ready -**Next Steps:** Integration مع MainActivity + permission request logic diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 9aaa5810..e0d537fd 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -12,7 +12,6 @@ android { defaultConfig { minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } @@ -62,16 +61,5 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.hilt.navigation.compose) - // Testing - testImplementation(libs.junit) - testImplementation(libs.mockk) - testImplementation(libs.turbine) - testImplementation(libs.kotlinx.coroutines.test) - - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) } diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/OnboardingComponents.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/OnboardingComponents.kt deleted file mode 100644 index 2a720cdb..00000000 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/OnboardingComponents.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.p2p.meshify.feature.onboarding - -import androidx.compose.animation.core.* -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.GenericShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Fill -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlin.math.abs -import kotlin.math.pow - -/** - * A proper Squircle (superellipse) shape implementation. - * n = 3.0 or 4.0 provides the "smooth" rounded corner look preferred by modern UI. - */ -fun SquircleShape(n: Float = 3.0f) = GenericShape { size, _ -> - val radius = size.width / 2f - val path = Path() - - // x = r * cos(t)^(2/n) - // y = r * sin(t)^(2/n) - // We iterate through 360 degrees - for (i in 0..360) { - val angle = Math.toRadians(i.toDouble()) - val cos = kotlin.math.cos(angle) - val sin = kotlin.math.sin(angle) - - val x = radius + radius * abs(cos).pow(2.0 / n).let { if (cos < 0) -it else it }.toFloat() - val y = radius + radius * abs(sin).pow(2.0 / n).let { if (sin < 0) -it else it }.toFloat() - - if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) - } - path.close() - this.addPath(path) -} - -/** - * An immersive, animated background for onboarding. - * Features morphing blobs and a noise texture for a high-end feel. - */ -@Composable -fun OnboardingBackground( - currentPage: Int, - modifier: Modifier = Modifier -) { - val primaryColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) - val secondaryColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.1f) - val tertiaryColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f) - - val infiniteTransition = rememberInfiniteTransition(label = "bg_blobs") - - // Animation for blob 1 - val blob1Offset by infiniteTransition.animateValue( - initialValue = Offset(0.2f, 0.2f), - targetValue = Offset(0.3f, 0.4f), - typeConverter = Offset.VectorConverter, - animationSpec = infiniteRepeatable( - animation = tween(8000, easing = LinearOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "blob1" - ) - - // Animation for blob 2 - val blob2Offset by infiniteTransition.animateValue( - initialValue = Offset(0.8f, 0.7f), - targetValue = Offset(0.7f, 0.5f), - typeConverter = Offset.VectorConverter, - animationSpec = infiniteRepeatable( - animation = tween(12000, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "blob2" - ) - - Box(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { - Canvas(modifier = Modifier.fillMaxSize().blur(80.dp).alpha(0.6f)) { - // Blob 1 - drawCircle( - brush = Brush.radialGradient( - colors = listOf(primaryColor, Color.Transparent), - center = Offset(size.width * blob1Offset.x, size.height * blob1Offset.y), - radius = size.minDimension * 0.8f - ), - center = Offset(size.width * blob1Offset.x, size.height * blob1Offset.y), - radius = size.minDimension * 0.8f - ) - - // Blob 2 - drawCircle( - brush = Brush.radialGradient( - colors = listOf(secondaryColor, Color.Transparent), - center = Offset(size.width * blob2Offset.x, size.height * blob2Offset.y), - radius = size.minDimension * 0.7f - ), - center = Offset(size.width * blob2Offset.x, size.height * blob2Offset.y), - radius = size.minDimension * 0.7f - ) - - // Subtle accent blob that moves based on page - val accentX = when (currentPage) { - 0 -> 0.1f - 1 -> 0.5f - else -> 0.9f - } - drawCircle( - brush = Brush.radialGradient( - colors = listOf(tertiaryColor, Color.Transparent), - center = Offset(size.width * accentX, size.height * 0.9f), - radius = size.minDimension * 0.5f - ), - center = Offset(size.width * accentX, size.height * 0.9f), - radius = size.minDimension * 0.5f - ) - } - } -} - -/** - * Animated page indicator using Squircle shapes. - */ -@Composable -fun SquirclePageIndicator( - currentPage: Int, - totalPages: Int, - onPageSelected: (Int) -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - repeat(totalPages) { index -> - val isActive = index == currentPage - - val width by animateDpAsState( - targetValue = if (isActive) 24.dp else 10.dp, - animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), - label = "width" - ) - - val alpha by animateFloatAsState( - targetValue = if (isActive) 1f else 0.3f, - animationSpec = tween(300), - label = "alpha" - ) - - Box( - modifier = Modifier - .width(width) - .height(10.dp) - .graphicsLayer { - shape = SquircleShape(if (isActive) 3.5f else 3.0f) - clip = true - } - .background( - color = if (isActive) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) - ) - .clickable { onPageSelected(index) } - ) - } - } -} diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/OnboardingPage.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/OnboardingPage.kt index 47d41ffd..6210f060 100644 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/OnboardingPage.kt +++ b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/OnboardingPage.kt @@ -1,14 +1,13 @@ package com.p2p.meshify.feature.onboarding -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.* -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.BluetoothSearching -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Wifi @@ -16,407 +15,127 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.p2p.meshify.core.ui.hooks.HapticPattern -import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem -import com.p2p.meshify.core.ui.theme.MeshifyPrimary -import com.p2p.meshify.core.ui.theme.StatusOnline - -// ============================================================ -// PAGE 1: WELCOME -// ============================================================ @Composable fun WelcomePage( - onLangMenuToggle: () -> Unit, - isLangMenuOpen: Boolean, - currentLang: String, - onLangSelected: (String) -> Unit, modifier: Modifier = Modifier ) { Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = MeshifyDesignSystem.Spacing.Xl), + modifier = modifier.fillMaxSize().padding(horizontal = MeshifyDesignSystem.Spacing.Xl), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.Center ) { - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xl)) - - // Large Premium Illustration Box( - modifier = Modifier - .size(280.dp) - .graphicsLayer { - shadowElevation = 20f - shape = SquircleShape(4.0f) - clip = true - } - .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)), + modifier = Modifier.size(120.dp).background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center ) { - OnboardingIllustration( - illustrationType = IllustrationType.MeshNetwork, - modifier = Modifier.size(200.dp) - ) - } - - // Title + Description - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md) - ) { - Text( - text = stringResource(R.string.ob_welcome_title), - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(R.string.ob_welcome_desc), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 1.4 - ) + Icon(Icons.Default.Hub, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary) } - // Satisfying Language Toggle - LanguageToggle( - currentLang = currentLang, - onLangSelected = onLangSelected - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xxl)) - } -} + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xl)) -@Composable -private fun LanguageToggle( - currentLang: String, - onLangSelected: (String) -> Unit, - modifier: Modifier = Modifier -) { - val haptics = LocalPremiumHaptics.current - - Surface( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), - shape = SquircleShape(4.0f), - modifier = modifier - .padding(bottom = MeshifyDesignSystem.Spacing.Lg) - .height(56.dp) - .width(220.dp) - ) { - Row( - modifier = Modifier.fillMaxSize().padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - LanguageOption( - label = stringResource(R.string.ob_lang_en), - isSelected = currentLang == "en", - onClick = { - if (currentLang != "en") { - haptics.perform(HapticPattern.Pop) - onLangSelected("en") - } - }, - modifier = Modifier.weight(1f) - ) - LanguageOption( - label = stringResource(R.string.ob_lang_ar), - isSelected = currentLang == "ar", - onClick = { - if (currentLang != "ar") { - haptics.perform(HapticPattern.Pop) - onLangSelected("ar") - } - }, - modifier = Modifier.weight(1f) - ) - } - } -} + Text(text = stringResource(R.string.ob_welcome_title), style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) -@Composable -private fun LanguageOption( - label: String, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val backgroundColor by animateColorAsState( - targetValue = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, - animationSpec = tween(400), - label = "bg" - ) - val contentColor by animateColorAsState( - targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, - animationSpec = tween(400), - label = "content" - ) + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md)) - Surface( - onClick = onClick, - color = backgroundColor, - contentColor = contentColor, - shape = SquircleShape(3.5f), - modifier = modifier.fillMaxHeight() - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.ExtraBold else FontWeight.Medium - ) - } + Text(text = stringResource(R.string.ob_welcome_desc), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) } } -// ============================================================ -// PAGE 2: HOW IT WORKS -// ============================================================ +private data class HowItWorksStep(val titleRes: Int, val descRes: Int, val icon: ImageVector) @Composable fun HowItWorksPage(modifier: Modifier = Modifier) { val steps = listOf( - HowItWorksStep( - titleRes = R.string.ob_step_discover_title, - descRes = R.string.ob_step_discover_desc, - illustrationType = IllustrationType.DiscoveryScreen - ), - HowItWorksStep( - titleRes = R.string.ob_step_connect_title, - descRes = R.string.ob_step_connect_desc, - illustrationType = IllustrationType.ConnectScreen - ), - HowItWorksStep( - titleRes = R.string.ob_step_chat_title, - descRes = R.string.ob_step_chat_desc, - illustrationType = IllustrationType.ChatScreen - ) + HowItWorksStep(R.string.ob_step_discover_title, R.string.ob_step_discover_desc, Icons.Default.Wifi), + HowItWorksStep(R.string.ob_step_connect_title, R.string.ob_step_connect_desc, Icons.Default.Devices), + HowItWorksStep(R.string.ob_step_chat_title, R.string.ob_step_chat_desc, Icons.Default.Forum) ) Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = MeshifyDesignSystem.Spacing.Lg) + modifier = modifier.fillMaxSize().padding(horizontal = MeshifyDesignSystem.Spacing.Lg), + verticalArrangement = Arrangement.Center ) { - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - - // Page title - Text( - text = stringResource(R.string.ob_how_title), - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) + Text(text = stringResource(R.string.ob_how_title), style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xl)) - // Vertical steps - Redesigned as Premium Cards Column(verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md)) { steps.forEachIndexed { index, step -> - StepCard( - stepNumber = index + 1, - titleRes = step.titleRes, - descRes = step.descRes, - illustrationType = step.illustrationType - ) + StepCard(stepNumber = index + 1, titleRes = step.titleRes, descRes = step.descRes, icon = step.icon) } } - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) } } @Composable -private fun StepCard( - stepNumber: Int, - titleRes: Int, - descRes: Int, - illustrationType: IllustrationType, - modifier: Modifier = Modifier -) { +private fun StepCard(stepNumber: Int, titleRes: Int, descRes: Int, icon: ImageVector, modifier: Modifier = Modifier) { Surface( modifier = modifier.fillMaxWidth(), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - border = null + shape = MeshifyDesignSystem.Shapes.Card, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(MeshifyDesignSystem.Spacing.Md), - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md), - verticalAlignment = Alignment.CenterVertically - ) { - // High-end Step Indicator + Row(modifier = Modifier.fillMaxWidth().padding(MeshifyDesignSystem.Spacing.Md), horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md), verticalAlignment = Alignment.CenterVertically) { Box( - modifier = Modifier - .size(56.dp) - .graphicsLayer { - shape = SquircleShape(3.0f) - clip = true - } - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + modifier = Modifier.size(56.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center ) { - OnboardingIllustration( - illustrationType = illustrationType, - modifier = Modifier.size(36.dp) - ) + Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) } - // Title + description - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(titleRes), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(descRes), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.3 - ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text(text = stringResource(titleRes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Text(text = stringResource(descRes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } } -// ============================================================ -// PAGE 3: PERMISSIONS OVERVIEW -// ============================================================ - @Composable -fun PermissionsOverviewPage( - permissions: List, - permissionStatuses: Map, - modifier: Modifier = Modifier -) { +fun PermissionsOverviewPage(permissions: List, permissionStatuses: Map, modifier: Modifier = Modifier) { Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = MeshifyDesignSystem.Spacing.Xl), + modifier = modifier.fillMaxSize().padding(horizontal = MeshifyDesignSystem.Spacing.Xl), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.Center ) { - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - - // Large Shield Illustration Box( - modifier = Modifier - .size(180.dp) - .graphicsLayer { - shape = SquircleShape(4.0f) - clip = true - } - .background(StatusOnline.copy(alpha = 0.1f)), + modifier = Modifier.size(100.dp).background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center ) { - OnboardingIllustration( - illustrationType = IllustrationType.ShieldCheck, - modifier = Modifier.size(120.dp) - ) + Icon(Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary) } - // Title + Description - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Sm) - ) { - Text( - text = stringResource(R.string.ob_perm_title), - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - Text( - text = stringResource(R.string.ob_perm_desc), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } + Text(text = stringResource(R.string.ob_perm_title), style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) - // Permission list - Redesigned - Surface( - modifier = Modifier.fillMaxWidth(), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) { - Column( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), - verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md) - ) { + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) + + Text(text = stringResource(R.string.ob_perm_desc), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xl)) + + Surface(modifier = Modifier.fillMaxWidth(), shape = MeshifyDesignSystem.Shapes.Card, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) { + Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md)) { permissions.forEach { perm -> - val status = permissionStatuses[perm.id] ?: PermissionStatus.NotAsked - PermissionRow( - iconType = perm.iconType, - labelRes = perm.labelRes, - importanceLabelRes = perm.importanceLabelRes, - status = status - ) + PermissionRow(perm.iconType, perm.labelRes, perm.importanceLabelRes, permissionStatuses[perm.id] ?: PermissionStatus.NotAsked, perm.isRequired) } } } - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) } } @Composable -private fun PermissionRow( - iconType: PermissionIconType, - labelRes: Int, - importanceLabelRes: Int, - status: PermissionStatus, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md) - ) { - // Tactile Icon - Box( - modifier = Modifier - .size(44.dp) - .graphicsLayer { - shape = SquircleShape(3.0f) - clip = true - } - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { +private fun PermissionRow(iconType: PermissionIconType, labelRes: Int, importanceLabelRes: Int, status: PermissionStatus, isRequired: Boolean, modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md)) { + Box(modifier = Modifier.size(44.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center) { Icon( imageVector = when (iconType) { PermissionIconType.Wifi -> Icons.Filled.Wifi @@ -424,31 +143,16 @@ private fun PermissionRow( PermissionIconType.Notifications -> Icons.Filled.Notifications PermissionIconType.Location -> Icons.Filled.LocationOn }, - contentDescription = null, - modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Large), - tint = MaterialTheme.colorScheme.primary + contentDescription = null, modifier = Modifier.size(MeshifyDesignSystem.IconSizes.Large), tint = MaterialTheme.colorScheme.primary ) } Column { - Text( - text = stringResource(labelRes), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(importanceLabelRes), - style = MaterialTheme.typography.labelSmall, - color = if (stringResource(importanceLabelRes) == stringResource(R.string.ob_perm_required)) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurfaceVariant - ) + Text(text = stringResource(labelRes), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Text(text = stringResource(importanceLabelRes), style = MaterialTheme.typography.labelSmall, color = if (isRequired) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) } } - // Status badge StatusBadge(status = status) } } @@ -457,284 +161,15 @@ private fun PermissionRow( private fun StatusBadge(status: PermissionStatus, modifier: Modifier = Modifier) { val (textRes, badgeColor) = when (status) { PermissionStatus.NotAsked -> R.string.ob_perm_not_asked to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - PermissionStatus.Granted -> R.string.ob_perm_granted to StatusOnline + PermissionStatus.Granted -> R.string.ob_perm_granted to MaterialTheme.colorScheme.primary PermissionStatus.Denied -> R.string.ob_perm_denied to MaterialTheme.colorScheme.error PermissionStatus.DeniedPermanently -> R.string.ob_perm_denied_permanent to MaterialTheme.colorScheme.error PermissionStatus.Skipped -> R.string.ob_perm_skipped to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - PermissionStatus.AlreadyGranted -> R.string.ob_perm_already_granted to StatusOnline + PermissionStatus.AlreadyGranted -> R.string.ob_perm_already_granted to MaterialTheme.colorScheme.primary } - val bgColor = badgeColor.copy(alpha = 0.1f) Text( - text = stringResource(textRes), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = badgeColor, - modifier = modifier - .graphicsLayer { - shape = SquircleShape(3.5f) - clip = true - } - .background(bgColor) - .padding(horizontal = MeshifyDesignSystem.Spacing.Sm, vertical = MeshifyDesignSystem.Spacing.Xxs) - ) -} - -// ============================================================ -// SHARED: ILLUSTRATIONS -// ============================================================ - -@Composable -fun OnboardingIllustration( - illustrationType: IllustrationType, - modifier: Modifier = Modifier -) { - when (illustrationType) { - IllustrationType.MeshNetwork -> MeshNetworkIllustration(modifier) - IllustrationType.DiscoveryScreen -> DiscoveryScreenIllustration(modifier) - IllustrationType.ConnectScreen -> ConnectScreenIllustration(modifier) - IllustrationType.ChatScreen -> ChatScreenIllustration(modifier) - IllustrationType.ShieldCheck -> ShieldCheckIllustration(modifier) - } -} - -@Composable -private fun MeshNetworkIllustration(modifier: Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "mesh") - // Continuous 360° rotation — LinearEasing is intentional here - // Spring physics would create unnatural oscillation for infinite rotation - @Suppress("BanLinearEasing") - val rotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(20000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "rot" - ) - - Canvas(modifier = modifier.rotate(rotation)) { - val center = Offset(size.width / 2, size.height / 2) - val radius = size.minDimension / 3 - val nodeCount = 6 - val nodes = List(nodeCount) { i -> - val angle = (i * 360f / nodeCount) * (Math.PI / 180f).toFloat() - Offset(center.x + radius * kotlin.math.cos(angle), center.y + radius * kotlin.math.sin(angle)) - } - - nodes.forEachIndexed { i, n1 -> - nodes.drop(i + 1).forEach { n2 -> - drawLine(MeshifyPrimary.copy(alpha = 0.2f), n1, n2, strokeWidth = 2.dp.toPx()) - } - } - nodes.forEach { node -> - drawCircle(MeshifyPrimary, 8.dp.toPx(), node) - } - drawCircle(StatusOnline, 14.dp.toPx(), center) - } -} - -@Composable -private fun DiscoveryScreenIllustration(modifier: Modifier) { - val outlineVariant = MaterialTheme.colorScheme.outlineVariant - val surfaceContainerHighest = MaterialTheme.colorScheme.surfaceContainerHighest - val primaryAlpha = MeshifyPrimary.copy(alpha = 0.15f) - val onSurfaceVariantAlpha = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - val primaryAlpha3 = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) - - Canvas(modifier = modifier) { - val w = size.width - val h = size.height - - // Phone frame - drawRoundRect( - color = outlineVariant, - size = Size(w, h), - cornerRadius = CornerRadius(16.dp.toPx()), - style = Stroke(width = 2.dp.toPx()) - ) - - // Top bar - drawRoundRect( - color = primaryAlpha, - topLeft = Offset(0f, 0f), - size = Size(w, h * 0.15f), - cornerRadius = CornerRadius(16.dp.toPx()) - ) - - // Peer items - val itemH = h * 0.12f - val gap = 4.dp.toPx() - for (i in 0 until 3) { - val y = h * 0.2f + i * (itemH + gap) - val alpha = 1f - i * 0.2f - drawRoundRect( - color = surfaceContainerHighest.copy(alpha = alpha), - topLeft = Offset(8.dp.toPx(), y), - size = Size(w - 16.dp.toPx(), itemH), - cornerRadius = CornerRadius(12.dp.toPx()) - ) - // Avatar circle - drawCircle( - color = primaryAlpha3, - radius = itemH * 0.35f, - center = Offset(w * 0.15f, y + itemH / 2) - ) - // Text lines - drawRoundRect( - color = onSurfaceVariantAlpha, - topLeft = Offset(w * 0.3f, y + itemH * 0.25f), - size = Size(w * 0.4f, 4.dp.toPx()), - cornerRadius = CornerRadius(2.dp.toPx()) - ) - } - } -} - -@Composable -private fun ConnectScreenIllustration(modifier: Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "connect") - val alpha by infiniteTransition.animateFloat( - initialValue = 0.2f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "pulse" - ) - val outlineColor = MaterialTheme.colorScheme.outline - - Canvas(modifier = modifier) { - val center = Offset(size.width / 2, size.height / 2) - val phoneW = size.width * 0.22f - val phoneH = size.height * 0.45f - - // Left phone - drawRoundRect( - color = MeshifyPrimary, - topLeft = Offset(center.x - size.width * 0.35f - phoneW / 2, center.y - phoneH / 2), - size = Size(phoneW, phoneH), - cornerRadius = CornerRadius(10.dp.toPx()) - ) - - // Right phone - drawRoundRect( - color = outlineColor, - topLeft = Offset(center.x + size.width * 0.35f - phoneW / 2, center.y - phoneH / 2), - size = Size(phoneW, phoneH), - cornerRadius = CornerRadius(10.dp.toPx()) - ) - - // Connection arc - val startX = center.x - size.width * 0.35f + phoneW / 2 - val endX = center.x + size.width * 0.35f - phoneW / 2 - - val path = Path().apply { - moveTo(startX, center.y) - quadraticTo(center.x, center.y - 30.dp.toPx(), endX, center.y) - } - drawPath(path, color = StatusOnline.copy(alpha = alpha), style = Stroke(width = 3.dp.toPx())) - - // Lock icon in center - drawCircle(StatusOnline.copy(alpha = 0.2f), 16.dp.toPx(), center) - drawCircle(StatusOnline, 8.dp.toPx(), center) - } -} - -@Composable -private fun ChatScreenIllustration(modifier: Modifier) { - val outlineVariant = MaterialTheme.colorScheme.outlineVariant - val surfaceContainerHighest = MaterialTheme.colorScheme.surfaceContainerHighest - val primaryAlpha8 = MeshifyPrimary.copy(alpha = 0.8f) - val statusOnlineAlpha = StatusOnline.copy(alpha = 0.3f) - - Canvas(modifier = modifier) { - val w = size.width - val h = size.height - - // Phone frame - drawRoundRect( - color = outlineVariant, - size = Size(w, h), - cornerRadius = CornerRadius(16.dp.toPx()), - style = Stroke(width = 2.dp.toPx()) - ) - - // Chat bubbles - val bubbleW = w * 0.65f - val bubbleH = h * 0.12f - val gap = 6.dp.toPx() - - // Bubble 1 — right (sent) - var y = h * 0.15f - drawRoundRect( - color = primaryAlpha8, - topLeft = Offset(w - bubbleW - 8.dp.toPx(), y), - size = Size(bubbleW, bubbleH), - cornerRadius = CornerRadius(12.dp.toPx()) - ) - - // Bubble 2 — left (received) - y += bubbleH + gap - drawRoundRect( - color = surfaceContainerHighest, - topLeft = Offset(8.dp.toPx(), y), - size = Size(bubbleW * 0.8f, bubbleH), - cornerRadius = CornerRadius(12.dp.toPx()) - ) - - // Bubble 3 — right (sent) - y += bubbleH + gap - drawRoundRect( - color = primaryAlpha8, - topLeft = Offset(w - bubbleW * 0.7f - 8.dp.toPx(), y), - size = Size(bubbleW * 0.7f, bubbleH * 0.8f), - cornerRadius = CornerRadius(12.dp.toPx()) - ) - - // Lock badge - drawCircle(StatusOnline.copy(alpha = 0.3f), 10.dp.toPx(), Offset(w / 2, h * 0.88f)) - } -} - -@Composable -private fun ShieldCheckIllustration(modifier: Modifier) { - val infiniteTransition = rememberInfiniteTransition(label = "shield") - val scale by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 1.05f, - animationSpec = infiniteRepeatable( - animation = tween(2000, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "scale" + text = stringResource(textRes), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = badgeColor, + modifier = modifier.background(badgeColor.copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.Pill).padding(horizontal = MeshifyDesignSystem.Spacing.Sm, vertical = MeshifyDesignSystem.Spacing.Xxs) ) - - Canvas(modifier = modifier) { - val center = Offset(size.width / 2, size.height / 2) - val shieldW = size.width * 0.6f - val shieldH = size.height * 0.7f - - // Shield - val shieldPath = Path().apply { - moveTo(center.x, center.y - shieldH / 2) - lineTo(center.x + shieldW / 2, center.y - shieldH / 3) - lineTo(center.x + shieldW / 2, center.y + shieldH / 3) - lineTo(center.x, center.y + shieldH / 2) - lineTo(center.x - shieldW / 2, center.y + shieldH / 3) - lineTo(center.x - shieldW / 2, center.y - shieldH / 3) - close() - } - drawPath( - path = shieldPath, - color = StatusOnline.copy(alpha = 0.2f), - style = Stroke(width = 3.dp.toPx()) - ) - - // Checkmark - val checkSize = shieldW * 0.3f - drawCircle(StatusOnline, checkSize / 2, center) - } } diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/PrePermissionDialog.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/PrePermissionDialog.kt deleted file mode 100644 index 53690ec4..00000000 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/PrePermissionDialog.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.p2p.meshify.feature.onboarding - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.ui.draw.clip -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.p2p.meshify.core.ui.hooks.HapticPattern -import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics -import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem - -/** - * Skip confirmation dialog shown when user tries to leave onboarding mid-flow. - */ -@Composable -fun SkipConfirmationDialog( - onStayClick: () -> Unit, - onLeaveClick: () -> Unit, - modifier: Modifier = Modifier -) { - val haptics = LocalPremiumHaptics.current - - Dialog( - onDismissRequest = onStayClick, - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true, - usePlatformDefaultWidth = false - ) - ) { - Surface( - modifier = modifier - .fillMaxWidth(0.92f) - .graphicsLayer { - shape = SquircleShape(4.0f) - clip = true - }, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 12.dp - ) { - Column( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Xl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Tactile Warning Icon - Box( - modifier = Modifier - .size(80.dp) - .graphicsLayer { - shape = SquircleShape(3.0f) - clip = true - } - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - modifier = Modifier.size(44.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - - Text( - text = stringResource(R.string.ob_skip_confirm_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) - - Text( - text = stringResource(R.string.ob_skip_confirm_desc), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3 - ) - - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xl)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md) - ) { - Surface( - onClick = { - haptics.perform(HapticPattern.Tick) - onStayClick() - }, - modifier = Modifier.weight(1f).height(56.dp), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.ob_skip_confirm_stay), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold - ) - } - } - - Surface( - onClick = { - haptics.perform(HapticPattern.Cancel) - onLeaveClick() - }, - modifier = Modifier.weight(1f).height(56.dp), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.ob_skip_confirm_leave), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Black - ) - } - } - } - } - } - } -} diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/SkipConfirmationDialog.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/SkipConfirmationDialog.kt new file mode 100644 index 00000000..4ddff1ce --- /dev/null +++ b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/SkipConfirmationDialog.kt @@ -0,0 +1,48 @@ +package com.p2p.meshify.feature.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem + +@Composable +fun SkipConfirmationDialog(onStayClick: () -> Unit, onLeaveClick: () -> Unit, modifier: Modifier = Modifier) { + Dialog(onDismissRequest = onStayClick, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true, usePlatformDefaultWidth = false)) { + Surface(modifier = modifier.fillMaxWidth(0.92f), shape = MeshifyDesignSystem.Shapes.Dialog, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 12.dp) { + Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Xl), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(80.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(44.dp), tint = MaterialTheme.colorScheme.primary) + } + + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) + Text(text = stringResource(R.string.ob_skip_confirm_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) + + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) + Text(text = stringResource(R.string.ob_skip_confirm_desc), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + + Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xl)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md)) { + Surface(onClick = onStayClick, modifier = Modifier.weight(1f).height(56.dp), shape = MeshifyDesignSystem.Shapes.Button, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { + Box(contentAlignment = Alignment.Center) { Text(text = stringResource(R.string.ob_skip_confirm_stay), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) } + } + + Surface(onClick = onLeaveClick, modifier = Modifier.weight(1f).height(56.dp), shape = MeshifyDesignSystem.Shapes.Button, color = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary) { + Box(contentAlignment = Alignment.Center) { Text(text = stringResource(R.string.ob_skip_confirm_leave), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) } + } + } + } + } + } +} diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt index 1986957a..0b255408 100644 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt +++ b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeScreen.kt @@ -2,17 +2,6 @@ package com.p2p.meshify.feature.onboarding import android.Manifest import android.os.Build -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -31,13 +20,11 @@ import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource -import com.p2p.meshify.core.ui.components.PremiumNoiseTexture import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -50,10 +37,6 @@ import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem import com.p2p.meshify.core.ui.theme.StatusOnline -/** - * Main onboarding screen composable. - * 3 pages: Welcome → How It Works → Permissions - */ @Composable fun WelcomeScreen( viewModel: WelcomeViewModel, @@ -61,256 +44,99 @@ fun WelcomeScreen( onLangChange: (String) -> Unit, onNextClick: () -> Unit, onSkipClick: () -> Unit, + permissionStatuses: Map = emptyMap(), modifier: Modifier = Modifier ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val haptics = LocalPremiumHaptics.current - - val pagerState = rememberPagerState( - initialPage = 0, - initialPageOffsetFraction = 0f - ) { 3 } + val pagerState = rememberPagerState(initialPage = 0, initialPageOffsetFraction = 0f) { 3 } LaunchedEffect(uiState.currentPage) { - if (pagerState.currentPage != uiState.currentPage) { - pagerState.animateScrollToPage( - page = uiState.currentPage, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 350f) - ) - } + if (pagerState.currentPage != uiState.currentPage) pagerState.animateScrollToPage(page = uiState.currentPage) } - LaunchedEffect(pagerState.currentPage) { - if (pagerState.currentPage != uiState.currentPage && !uiState.isAnimating) { - viewModel.goToPage(pagerState.currentPage) + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + if (page != uiState.currentPage && !uiState.isAnimating) viewModel.goToPage(page) } } Box(modifier = modifier.fillMaxSize()) { - // Dynamic immersive background - OnboardingBackground(currentPage = uiState.currentPage) - - // Noise texture for tactile feel - PremiumNoiseTexture(alpha = 0.04f) - Column(modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) { - // Top bar: Language chip + Skip TopBar( - currentLang = currentLang, - onLangMenuToggle = { viewModel.toggleLangMenu() }, - isLangMenuOpen = uiState.isLangMenuOpen, - onLangSelected = { lang -> - haptics.perform(HapticPattern.Pop) - viewModel.toggleLangMenu() - onLangChange(lang) - }, - onSkipClick = { - haptics.perform(HapticPattern.Cancel) - onSkipClick() - } + currentLang = currentLang, onLangMenuToggle = { viewModel.toggleLangMenu() }, + isLangMenuOpen = uiState.isLangMenuOpen, onLangSelected = { onLangChange(it) }, onSkipClick = { onSkipClick() } ) - // Pages - HorizontalPager( - state = pagerState, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = MeshifyDesignSystem.Spacing.Md) - ) { page -> - // Apply a parallax effect to the page content - val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction - - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - translationX = pageOffset * size.width * 0.5f - alpha = 1f - kotlin.math.abs(pageOffset).coerceIn(0f, 1f) - } - ) { + HorizontalPager(state = pagerState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = MeshifyDesignSystem.Spacing.Md)) { page -> + Box(modifier = Modifier.fillMaxSize()) { when (page) { - 0 -> WelcomePage( - onLangMenuToggle = { viewModel.toggleLangMenu() }, - isLangMenuOpen = uiState.isLangMenuOpen, - currentLang = currentLang, - onLangSelected = { lang -> - haptics.perform(HapticPattern.Pop) - viewModel.toggleLangMenu() - onLangChange(lang) - }, - modifier = Modifier.fillMaxSize() - ) - + 0 -> WelcomePage(modifier = Modifier.fillMaxSize()) 1 -> HowItWorksPage(modifier = Modifier.fillMaxSize()) - - 2 -> PermissionsOverviewPage( - permissions = PermissionDefinitions.getPermissions(), - permissionStatuses = emptyMap(), - modifier = Modifier.fillMaxSize() - ) + 2 -> PermissionsOverviewPage(permissions = PermissionDefinitions.getPermissions(), permissionStatuses = permissionStatuses, modifier = Modifier.fillMaxSize()) } } } - // Bottom: Page dots + Next / Get Started button BottomNav( - currentPage = uiState.currentPage, - totalPages = 3, - isAnimating = uiState.isAnimating, - onPageSelected = { pageIndex -> - haptics.perform(HapticPattern.Tick) - viewModel.goToPage(pageIndex) - }, - onNextClick = { - haptics.perform(HapticPattern.Pop) - if (uiState.currentPage < 2) { - viewModel.nextPage() - } else { - onNextClick() - } - } + currentPage = uiState.currentPage, totalPages = 3, isAnimating = uiState.isAnimating, + onPageSelected = { haptics.perform(HapticPattern.Tick); viewModel.goToPage(it) }, + onNextClick = { haptics.perform(HapticPattern.Pop); if (uiState.currentPage < 2) viewModel.nextPage() else onNextClick() } ) } } } -// ============================================================ -// TOP BAR -// ============================================================ - @Composable -private fun TopBar( - currentLang: String, - onLangMenuToggle: () -> Unit, - isLangMenuOpen: Boolean, - onLangSelected: (String) -> Unit, - onSkipClick: () -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .fillMaxWidth() - .padding( - horizontal = MeshifyDesignSystem.Spacing.Md, - vertical = MeshifyDesignSystem.Spacing.Xs - ) - ) { - // Language chip - Stylized as a Squircle +private fun TopBar(currentLang: String, onLangMenuToggle: () -> Unit, isLangMenuOpen: Boolean, onLangSelected: (String) -> Unit, onSkipClick: () -> Unit, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth().padding(horizontal = MeshifyDesignSystem.Spacing.Md, vertical = MeshifyDesignSystem.Spacing.Xs)) { Surface( - onClick = onLangMenuToggle, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = SquircleShape(4.0f), - modifier = Modifier.height(40.dp) + onClick = onLangMenuToggle, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MeshifyDesignSystem.Shapes.Pill, modifier = Modifier.height(40.dp) ) { - Row( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Xs) - ) { - Icon( - Icons.Default.Language, - contentDescription = stringResource(R.string.ob_cd_lang_switch), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = if (currentLang == "ar") stringResource(R.string.ob_lang_ar) else stringResource(R.string.ob_lang_en), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold - ) + Row(modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Xs)) { + Icon(Icons.Default.Language, contentDescription = stringResource(R.string.ob_cd_lang_switch), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Text(text = if (currentLang == "ar") stringResource(R.string.ob_lang_ar) else stringResource(R.string.ob_lang_en), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) } } - DropdownMenu( - expanded = isLangMenuOpen, - onDismissRequest = onLangMenuToggle, - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .graphicsLayer { - shape = SquircleShape(3.5f) - clip = true - } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.ob_lang_en)) }, - onClick = { onLangSelected("en") }, - leadingIcon = { - if (currentLang == "en") { - Icon(Icons.Default.Check, null, modifier = Modifier.size(18.dp)) - } - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.ob_lang_ar)) }, - onClick = { onLangSelected("ar") }, - leadingIcon = { - if (currentLang == "ar") { - Icon(Icons.Default.Check, null, modifier = Modifier.size(18.dp)) - } - } - ) + DropdownMenu(expanded = isLangMenuOpen, onDismissRequest = onLangMenuToggle, modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh)) { + DropdownMenuItem(text = { Text(stringResource(R.string.ob_lang_en)) }, onClick = { onLangSelected("en") }, leadingIcon = { if (currentLang == "en") Icon(Icons.Default.Check, null, modifier = Modifier.size(18.dp)) }) + DropdownMenuItem(text = { Text(stringResource(R.string.ob_lang_ar)) }, onClick = { onLangSelected("ar") }, leadingIcon = { if (currentLang == "ar") Icon(Icons.Default.Check, null, modifier = Modifier.size(18.dp)) }) } - // Skip button - TextButton( - onClick = onSkipClick, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Text( - text = stringResource(R.string.ob_btn_skip), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + + TextButton(onClick = onSkipClick, modifier = Modifier.align(Alignment.CenterEnd)) { + Text(text = stringResource(R.string.ob_btn_skip), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } -// ============================================================ -// BOTTOM NAV -// ============================================================ - @Composable -private fun BottomNav( - currentPage: Int, - totalPages: Int, - isAnimating: Boolean, - onPageSelected: (Int) -> Unit, - onNextClick: () -> Unit, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .fillMaxWidth() - .padding(MeshifyDesignSystem.Spacing.Lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Lg) - ) { - // Squircle Page Indicator - SquirclePageIndicator( - currentPage = currentPage, - totalPages = totalPages, - onPageSelected = onPageSelected - ) - - // Button - High tactile feel Squircle +private fun BottomNav(currentPage: Int, totalPages: Int, isAnimating: Boolean, onPageSelected: (Int) -> Unit, onNextClick: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth().padding(MeshifyDesignSystem.Spacing.Lg), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Lg)) { + val pageIndicatorDesc = stringResource(R.string.ob_cd_page_indicator) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + repeat(totalPages) { index -> + val isActive = index == currentPage + Box( + modifier = Modifier + .size(if (isActive) 24.dp else 10.dp, 10.dp) + .background(color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), shape = RoundedCornerShape(4.dp)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { onPageSelected(index) }) + .semantics { contentDescription = pageIndicatorDesc } + ) + } + } + Surface( - onClick = onNextClick, - enabled = !isAnimating, - color = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - shape = SquircleShape(3.5f), - modifier = Modifier - .fillMaxWidth() - .height(64.dp) + onClick = onNextClick, enabled = !isAnimating, color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, shape = MeshifyDesignSystem.Shapes.Button, + modifier = Modifier.fillMaxWidth().height(56.dp) ) { Box(contentAlignment = Alignment.Center) { Text( - text = if (currentPage == 2) { - stringResource(R.string.ob_btn_get_started) - } else { - stringResource(R.string.ob_btn_next) - }, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold + text = if (currentPage == 2) stringResource(R.string.ob_btn_get_started) else stringResource(R.string.ob_btn_next), + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) } } @@ -318,99 +144,19 @@ private fun BottomNav( } @Composable -private fun PageDot( - isActive: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val pageIndicatorDesc = stringResource(R.string.ob_cd_page_indicator) - val animateFloat by animateFloatAsState( - targetValue = if (isActive) 12f else 8f, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 400f), - label = "dotSize" - ) - - Box( - modifier = modifier - .size(animateFloat.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - color = if (isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - } - ) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick - ) - .semantics { contentDescription = pageIndicatorDesc } - ) -} - -// ============================================================ -// PERMISSION CARD (Slide-up modal) -// ============================================================ - -@Composable -fun PermissionRequestCard( - permission: PermissionInfo, - onAllowClick: () -> Unit, - onDenyClick: () -> Unit, - onRequestDismiss: () -> Unit, - modifier: Modifier = Modifier -) { +fun PermissionRequestCard(permission: PermissionInfo, onAllowClick: () -> Unit, onDenyClick: () -> Unit, onRequestDismiss: () -> Unit, modifier: Modifier = Modifier) { val haptics = LocalPremiumHaptics.current - AnimatedVisibility( - visible = true, - enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f) - ) + fadeIn(tween(300)), - exit = slideOutVertically( - targetOffsetY = { it }, - animationSpec = tween(250) - ) + fadeOut(tween(250)) + Box( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onRequestDismiss) ) { - // Dim background - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onRequestDismiss - ) - ) { - // Card - Premium Squircle Surface( - modifier = modifier - .fillMaxWidth(0.92f) - .align(Alignment.BottomCenter) - .padding(bottom = MeshifyDesignSystem.Spacing.Xl), - shape = SquircleShape(4.0f), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 8.dp + modifier = modifier.fillMaxWidth(0.92f).align(Alignment.BottomCenter).padding(bottom = MeshifyDesignSystem.Spacing.Xl), + shape = MeshifyDesignSystem.Shapes.Dialog, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 8.dp ) { - Column( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Lg), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Tactile Icon Container - Box( - modifier = Modifier - .size(80.dp) - .graphicsLayer { - shape = SquircleShape(3.0f) - clip = true - } - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { + Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Lg), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(80.dp).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center) { Icon( imageVector = when (permission.iconType) { PermissionIconType.Wifi -> Icons.Filled.Wifi @@ -418,503 +164,149 @@ fun PermissionRequestCard( PermissionIconType.Notifications -> Icons.Filled.Notifications PermissionIconType.Location -> Icons.Filled.LocationOn }, - contentDescription = null, - modifier = Modifier.size(MeshifyDesignSystem.IconSizes.XXL), - tint = MaterialTheme.colorScheme.primary + contentDescription = null, modifier = Modifier.size(MeshifyDesignSystem.IconSizes.XXL), tint = MaterialTheme.colorScheme.primary ) } Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - Text( - text = stringResource(permission.labelRes), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) + Text(text = stringResource(permission.labelRes), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md)) - // What happens - Premium Card - InfoSection( - titleRes = R.string.ob_card_why_title, - pointsRes = permission.whatHappensRes, - iconTint = MaterialTheme.colorScheme.primary - ) - + InfoSection(titleRes = R.string.ob_card_why_title, pointsRes = permission.whatHappensRes, iconTint = MaterialTheme.colorScheme.primary) Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) - - // If deny - Premium Card - InfoSection( - titleRes = R.string.ob_card_deny_title, - pointsRes = permission.ifDenyRes, - iconTint = MaterialTheme.colorScheme.error - ) + InfoSection(titleRes = R.string.ob_card_deny_title, pointsRes = permission.ifDenyRes, iconTint = MaterialTheme.colorScheme.error) Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - // Buttons - Premium Squircles - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md) - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Md)) { Surface( - onClick = { - haptics.perform(HapticPattern.Tick) - onDenyClick() - }, - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + onClick = { haptics.perform(HapticPattern.Tick); onDenyClick() }, modifier = Modifier.weight(1f).height(56.dp), + shape = MeshifyDesignSystem.Shapes.Button, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.ob_card_deny), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Box(contentAlignment = Alignment.Center) { Text(text = stringResource(R.string.ob_card_deny), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant) } } Surface( - onClick = { - haptics.perform(HapticPattern.Pop) - onAllowClick() - }, - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + onClick = { haptics.perform(HapticPattern.Pop); onAllowClick() }, modifier = Modifier.weight(1f).height(56.dp), + shape = MeshifyDesignSystem.Shapes.Button, color = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.ob_card_allow), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Black - ) - } + Box(contentAlignment = Alignment.Center) { Text(text = stringResource(R.string.ob_card_allow), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) } } } } } } } -} @Composable -private fun InfoSection( - titleRes: Int, - pointsRes: List, - iconTint: Color, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) { +private fun InfoSection(titleRes: Int, pointsRes: List, iconTint: Color, modifier: Modifier = Modifier) { + Surface(modifier = modifier.fillMaxWidth(), shape = MeshifyDesignSystem.Shapes.CardSmall, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) { Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md)) { - Text( - text = stringResource(titleRes), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Black, - color = iconTint - ) - + Text(text = stringResource(titleRes), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = iconTint) Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Xs)) - pointsRes.forEach { pointRes -> - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Xs), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = iconTint - ) - Text( - text = stringResource(pointRes), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2 - ) + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Xs), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(14.dp), tint = iconTint) + Text(text = stringResource(pointRes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } } } -// ============================================================ -// PERMISSION RESULT CARD -// ============================================================ - @Composable -fun PermissionResultCard( - permission: PermissionInfo, - result: PermissionRequestResult, - modifier: Modifier = Modifier -) { +fun PermissionResultCard(permission: PermissionInfo, result: PermissionRequestResult, modifier: Modifier = Modifier) { val (icon, iconTint, statusText) = when (result) { PermissionRequestResult.Granted -> Triple(Icons.Default.Check, StatusOnline, R.string.ob_perm_granted) PermissionRequestResult.Denied -> Triple(Icons.Default.Close, MaterialTheme.colorScheme.error, R.string.ob_perm_denied) PermissionRequestResult.DeniedPermanently -> Triple(Icons.Default.Warning, MaterialTheme.colorScheme.error, R.string.ob_perm_denied_permanent) } - AnimatedVisibility( - visible = true, - enter = scaleIn( - initialScale = 0.85f, - animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f) - ) + fadeIn(tween(250)) - ) { - Surface( - modifier = modifier.fillMaxWidth(0.92f), - shape = SquircleShape(4.0f), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 4.dp - ) { - Row( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Lg), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Lg) - ) { - Box( - modifier = Modifier - .size(64.dp) - .graphicsLayer { - shape = SquircleShape(3.0f) - clip = true - } - .background(iconTint.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(MeshifyDesignSystem.IconSizes.XXL), - tint = iconTint - ) - } + Surface(modifier = modifier.fillMaxWidth(0.92f), shape = MeshifyDesignSystem.Shapes.Dialog, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 4.dp) { + Row(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Lg), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Lg)) { + Box(modifier = Modifier.size(64.dp).background(iconTint.copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, modifier = Modifier.size(MeshifyDesignSystem.IconSizes.XXL), tint = iconTint) + } - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(permission.labelRes), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(statusText), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = iconTint - ) - } + Column(modifier = Modifier.weight(1f)) { + Text(text = stringResource(permission.labelRes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Text(text = stringResource(statusText), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = iconTint) } } } } -// ============================================================ -// SUMMARY DIALOG -// ============================================================ - @Composable -fun PermissionSummaryDialog( - grantedCount: Int, - totalCount: Int, - permissionResults: Map, - onStartClick: () -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { +fun PermissionSummaryDialog(grantedCount: Int, totalCount: Int, permissionResults: Map, onStartClick: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier) { val haptics = LocalPremiumHaptics.current - var showDetails by remember { mutableStateOf(false) } val allGranted = grantedCount == totalCount - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - dismissOnBackPress = false, - dismissOnClickOutside = false, - usePlatformDefaultWidth = false - ) - ) { - Surface( - modifier = modifier - .fillMaxWidth(0.92f) - .graphicsLayer { - shape = SquircleShape(4.0f) - clip = true - }, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 12.dp - ) { - Column( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Xl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Success icon - Box( - modifier = Modifier - .size(88.dp) - .graphicsLayer { - shape = SquircleShape(3.5f) - clip = true - } - .background(color = (if (allGranted) StatusOnline else MaterialTheme.colorScheme.error).copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (allGranted) Icons.Default.Check else Icons.Default.Warning, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = if (allGranted) StatusOnline else MaterialTheme.colorScheme.error - ) + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false, usePlatformDefaultWidth = false)) { + Surface(modifier = modifier.fillMaxWidth(0.92f), shape = MeshifyDesignSystem.Shapes.Dialog, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 12.dp) { + Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Xl), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(88.dp).background(color = (if (allGranted) StatusOnline else MaterialTheme.colorScheme.error).copy(alpha = 0.1f), shape = MeshifyDesignSystem.Shapes.IconContainer), contentAlignment = Alignment.Center) { + Icon(imageVector = if (allGranted) Icons.Default.Check else Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(48.dp), tint = if (allGranted) StatusOnline else MaterialTheme.colorScheme.error) } Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - - Text( - text = stringResource(R.string.ob_summary_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - + Text(text = stringResource(R.string.ob_summary_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Sm)) - - Text( - text = stringResource(R.string.ob_summary_desc), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3 - ) + Text(text = stringResource(R.string.ob_summary_desc), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Md)) - Surface( - shape = SquircleShape(3.0f), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Surface(shape = MeshifyDesignSystem.Shapes.CardSmall, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(MeshifyDesignSystem.Spacing.Md), horizontalAlignment = Alignment.CenterHorizontally) { if (!allGranted) { - Text( - text = stringResource(R.string.ob_summary_count, grantedCount, totalCount), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(R.string.ob_summary_partial), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) + Text(text = stringResource(R.string.ob_summary_count, grantedCount, totalCount), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Text(text = stringResource(R.string.ob_summary_partial), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center) } else { - Text( - text = stringResource(R.string.ob_summary_all_granted), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Black, - color = StatusOnline - ) - } - } - } - - // Expandable details - AnimatedVisibility( - visible = showDetails, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = MeshifyDesignSystem.Spacing.Md), - verticalArrangement = Arrangement.spacedBy(MeshifyDesignSystem.Spacing.Xs) - ) { - permissionResults.forEach { (id, result) -> - val perm = PermissionDefinitions.getPermissions().find { it.id == id } ?: return@forEach - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(perm.labelRes), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - val (statusText, color) = when (result) { - PermissionRequestResult.Granted -> R.string.ob_perm_granted to StatusOnline - PermissionRequestResult.Denied -> R.string.ob_perm_denied to MaterialTheme.colorScheme.error - PermissionRequestResult.DeniedPermanently -> R.string.ob_perm_denied_permanent to MaterialTheme.colorScheme.error - } - Text( - text = stringResource(statusText), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - color = color - ) - } + Text(text = stringResource(R.string.ob_summary_all_granted), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = StatusOnline) } } } - TextButton( - onClick = { - showDetails = !showDetails - haptics.perform(HapticPattern.Tick) - }, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Text( - text = if (showDetails) "Hide details" else stringResource(R.string.ob_summary_view_details), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - Spacer(modifier = Modifier.height(MeshifyDesignSystem.Spacing.Lg)) Surface( - onClick = { - haptics.perform(HapticPattern.Success) - onStartClick() - }, - modifier = Modifier - .fillMaxWidth() - .height(64.dp), - shape = SquircleShape(3.5f), - color = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary + onClick = { haptics.perform(HapticPattern.Success); onStartClick() }, modifier = Modifier.fillMaxWidth().height(56.dp), + shape = MeshifyDesignSystem.Shapes.Button, color = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.ob_summary_start), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Black - ) - } + Box(contentAlignment = Alignment.Center) { Text(text = stringResource(R.string.ob_summary_start), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) } } } } } } -// ============================================================ -// PERMISSION DEFINITIONS -// ============================================================ - object PermissionDefinitions { - fun getPermissions(): List { val permissions = mutableListOf() - // 1. Nearby WiFi (Android 13+) or Location (Android < 13) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions.add( - PermissionInfo( - id = "nearby_wifi", - iconType = PermissionIconType.Wifi, - labelRes = R.string.ob_perm_label_nearby, - importanceLabelRes = R.string.ob_perm_required, - isRequired = true, - whatHappensRes = listOf( - R.string.ob_perm_nearby_why_1, - R.string.ob_perm_nearby_why_2 - ), - ifDenyRes = listOf( - R.string.ob_perm_nearby_deny_1, - R.string.ob_perm_nearby_deny_2 - ), - androidPermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES) - ) - ) + permissions.add(PermissionInfo(id = "nearby_wifi", iconType = PermissionIconType.Wifi, labelRes = R.string.ob_perm_label_nearby, importanceLabelRes = R.string.ob_perm_required, isRequired = true, + whatHappensRes = listOf(R.string.ob_perm_nearby_why_1, R.string.ob_perm_nearby_why_2), + ifDenyRes = listOf(R.string.ob_perm_nearby_deny_1, R.string.ob_perm_nearby_deny_2), + androidPermissions = listOf(Manifest.permission.NEARBY_WIFI_DEVICES))) } else { - permissions.add( - PermissionInfo( - id = "location", - iconType = PermissionIconType.Location, - labelRes = R.string.ob_perm_label_nearby, - importanceLabelRes = R.string.ob_perm_required, - isRequired = true, - whatHappensRes = listOf( - R.string.ob_perm_loc_why_1, - R.string.ob_perm_loc_why_2 - ), - ifDenyRes = listOf( - R.string.ob_perm_loc_deny_1, - R.string.ob_perm_loc_deny_2 - ), - androidPermissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION) - ) - ) + permissions.add(PermissionInfo(id = "location", iconType = PermissionIconType.Location, labelRes = R.string.ob_perm_label_nearby, importanceLabelRes = R.string.ob_perm_required, isRequired = true, + whatHappensRes = listOf(R.string.ob_perm_loc_why_1, R.string.ob_perm_loc_why_2), + ifDenyRes = listOf(R.string.ob_perm_loc_deny_1, R.string.ob_perm_loc_deny_2), + androidPermissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))) } - // 2. Bluetooth - permissions.add( - PermissionInfo( - id = "bluetooth", - iconType = PermissionIconType.Bluetooth, - labelRes = R.string.ob_perm_label_bt, - importanceLabelRes = R.string.ob_perm_optional, - isRequired = false, - whatHappensRes = listOf( - R.string.ob_perm_bt_why_1, - R.string.ob_perm_bt_why_2 - ), - ifDenyRes = listOf( - R.string.ob_perm_bt_deny_1, - R.string.ob_perm_bt_deny_2 - ), - androidPermissions = listOf( - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.BLUETOOTH_ADVERTISE - ) - ) - ) + permissions.add(PermissionInfo(id = "bluetooth", iconType = PermissionIconType.Bluetooth, labelRes = R.string.ob_perm_label_bt, importanceLabelRes = R.string.ob_perm_optional, isRequired = false, + whatHappensRes = listOf(R.string.ob_perm_bt_why_1, R.string.ob_perm_bt_why_2), + ifDenyRes = listOf(R.string.ob_perm_bt_deny_1, R.string.ob_perm_bt_deny_2), + androidPermissions = listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE))) - // 3. Notifications if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissions.add( - PermissionInfo( - id = "notifications", - iconType = PermissionIconType.Notifications, - labelRes = R.string.ob_perm_label_notif, - importanceLabelRes = R.string.ob_perm_optional, - isRequired = false, - whatHappensRes = listOf( - R.string.ob_perm_notif_why_1, - R.string.ob_perm_notif_why_2 - ), - ifDenyRes = listOf( - R.string.ob_perm_notif_deny_1, - R.string.ob_perm_notif_deny_2 - ), - androidPermissions = listOf(Manifest.permission.POST_NOTIFICATIONS) - ) - ) + permissions.add(PermissionInfo(id = "notifications", iconType = PermissionIconType.Notifications, labelRes = R.string.ob_perm_label_notif, importanceLabelRes = R.string.ob_perm_optional, isRequired = false, + whatHappensRes = listOf(R.string.ob_perm_notif_why_1, R.string.ob_perm_notif_why_2), + ifDenyRes = listOf(R.string.ob_perm_notif_deny_1, R.string.ob_perm_notif_deny_2), + androidPermissions = listOf(Manifest.permission.POST_NOTIFICATIONS))) } return permissions diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt index 8222386d..aa07a906 100644 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt +++ b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeUiState.kt @@ -1,67 +1,31 @@ package com.p2p.meshify.feature.onboarding -/** - * UI state for the onboarding flow. - * Tracks current page, language, animation state, and permission progress. - */ data class WelcomeUiState( val currentPage: Int = 0, val totalPages: Int = 3, val isAnimating: Boolean = false, - val isLangMenuOpen: Boolean = false, - val isPermissionFlowActive: Boolean = false, - val isSummaryVisible: Boolean = false + val isLangMenuOpen: Boolean = false ) -/** - * Represents a single step in the "How It Works" page. - */ -data class HowItWorksStep( - val titleRes: Int, - val descRes: Int, - val illustrationType: IllustrationType -) - -/** - * Data class representing a permission that needs explanation + request. - */ data class PermissionInfo( val id: String, val iconType: PermissionIconType, val labelRes: Int, - val importanceLabelRes: Int, // "Required" or "Optional" + val importanceLabelRes: Int, val isRequired: Boolean, val whatHappensRes: List, val ifDenyRes: List, - val androidPermissions: List, // The actual Android permissions to request - val initialStatus: PermissionStatus = PermissionStatus.NotAsked + val androidPermissions: List, ) -/** - * Status of a single permission after request. - */ enum class PermissionStatus { - NotAsked, - Granted, - Denied, - DeniedPermanently, - Skipped, - AlreadyGranted + NotAsked, Granted, Denied, DeniedPermanently, Skipped, AlreadyGranted } -/** - * Icon types for permission cards (avoids Android dependency in domain layer). - */ -sealed class PermissionIconType { - object Wifi : PermissionIconType() - object Bluetooth : PermissionIconType() - object Notifications : PermissionIconType() - object Location : PermissionIconType() +enum class PermissionIconType { + Wifi, Bluetooth, Notifications, Location } -/** - * The result of requesting a single permission from Android. - */ sealed class PermissionRequestResult { object Granted : PermissionRequestResult() object Denied : PermissionRequestResult() diff --git a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeViewModel.kt b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeViewModel.kt index 0358acb9..cf990198 100644 --- a/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeViewModel.kt +++ b/feature/onboarding/src/main/java/com/p2p/meshify/feature/onboarding/WelcomeViewModel.kt @@ -3,6 +3,7 @@ package com.p2p.meshify.feature.onboarding import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -10,109 +11,35 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -/** - * ViewModel for the onboarding flow. - * Manages page navigation, language state, and permission tracking. - * - * Note: This ViewModel does NOT request Android permissions directly. - * Permission requests are handled by the Activity via callbacks. - */ @HiltViewModel class WelcomeViewModel @Inject constructor() : ViewModel() { private val _uiState = MutableStateFlow(WelcomeUiState()) val uiState: StateFlow = _uiState.asStateFlow() - /** - * Navigate to the next page. - */ fun nextPage() { val currentState = _uiState.value if (currentState.currentPage < currentState.totalPages - 1 && !currentState.isAnimating) { - _uiState.update { - it.copy( - currentPage = it.currentPage + 1, - isAnimating = true - ) - } + _uiState.update { it.copy(currentPage = it.currentPage + 1, isAnimating = true) } viewModelScope.launch { - kotlinx.coroutines.delay(300) + delay(300) _uiState.update { it.copy(isAnimating = false) } } } } - /** - * Navigate to a specific page. - */ fun goToPage(pageIndex: Int) { val currentState = _uiState.value - if (pageIndex in 0 until currentState.totalPages && - pageIndex != currentState.currentPage && - !currentState.isAnimating) { - _uiState.update { - it.copy( - currentPage = pageIndex, - isAnimating = true - ) - } + if (pageIndex in 0 until currentState.totalPages && pageIndex != currentState.currentPage && !currentState.isAnimating) { + _uiState.update { it.copy(currentPage = pageIndex, isAnimating = true) } viewModelScope.launch { - kotlinx.coroutines.delay(300) + delay(300) _uiState.update { it.copy(isAnimating = false) } } } } - /** - * Toggle the language menu. - */ fun toggleLangMenu() { _uiState.update { it.copy(isLangMenuOpen = !it.isLangMenuOpen) } } - - /** - * Start the permission flow (transition to showing permission cards). - */ - fun startPermissionFlow() { - _uiState.update { - it.copy( - isPermissionFlowActive = true, - isAnimating = true - ) - } - viewModelScope.launch { - kotlinx.coroutines.delay(200) - _uiState.update { it.copy(isAnimating = false) } - } - } - - /** - * Show the summary dialog after permissions are processed. - */ - fun showSummary() { - _uiState.update { - it.copy( - isPermissionFlowActive = false, - isSummaryVisible = true - ) - } - } - - /** - * Dismiss the summary dialog. - */ - fun dismissSummary() { - _uiState.update { it.copy(isSummaryVisible = false) } - } -} - -/** - * Sealed class for illustration types used in onboarding. - */ -sealed class IllustrationType { - object MeshNetwork : IllustrationType() - object DiscoveryScreen : IllustrationType() - object ConnectScreen : IllustrationType() - object ChatScreen : IllustrationType() - object ShieldCheck : IllustrationType() } diff --git a/feature/onboarding/src/main/res/values-ar/strings.xml b/feature/onboarding/src/main/res/values-ar/strings.xml index 9707dc70..6ed23692 100644 --- a/feature/onboarding/src/main/res/values-ar/strings.xml +++ b/feature/onboarding/src/main/res/values-ar/strings.xml @@ -74,8 +74,7 @@ تم رفض بعض الأذونات. يمكنك تغييرها من الإعدادات في أي وقت. جميع الأذونات ممنوحة تم منح %1$d من %2$d - عرض التفاصيل - بدء المراسلة + ابدأ Meshify مغادرة الإعداد؟ diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml index d8a15ef6..8c4792c2 100644 --- a/feature/onboarding/src/main/res/values/strings.xml +++ b/feature/onboarding/src/main/res/values/strings.xml @@ -74,8 +74,7 @@ Some permissions were denied. You can change them in Settings anytime. All permissions granted %1$d of %2$d granted - View details - Start Messaging + Start Meshify Leave onboarding? diff --git a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/DiscoveredPeerList.kt b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/DiscoveredPeerList.kt index 06ddde5d..6155fb5a 100644 --- a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/DiscoveredPeerList.kt +++ b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/DiscoveredPeerList.kt @@ -1,6 +1,7 @@ package com.p2p.meshify.feature.realdevicetesting.ui import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,7 +54,7 @@ fun DiscoveredPeerList( ) { Card( modifier = modifier.fillMaxWidth(), - shape = MeshifyDesignSystem.Shapes.CardMedium, + shape = MeshifyDesignSystem.Shapes.Card, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) @@ -136,7 +137,7 @@ private fun PeerListItem( } else { MaterialTheme.colorScheme.surfaceContainerHigh }, - animationSpec = MeshifyDesignSystem.Motion.expressiveSpring(), + animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), label = "peer_background" ) @@ -146,7 +147,7 @@ private fun PeerListItem( } else { Color.Transparent }, - animationSpec = MeshifyDesignSystem.Motion.expressiveSpring(), + animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), label = "peer_border" ) diff --git a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/PreFlightResultsCard.kt b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/PreFlightResultsCard.kt index ce4b280e..daec23bf 100644 --- a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/PreFlightResultsCard.kt +++ b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/PreFlightResultsCard.kt @@ -1,6 +1,7 @@ package com.p2p.meshify.feature.realdevicetesting.ui import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -51,15 +52,15 @@ fun PreFlightResultsCard( var expanded by remember { mutableStateOf(false) } val rotation by animateFloatAsState( targetValue = if (expanded) 90f else 0f, - animationSpec = MeshifyDesignSystem.Motion.expressiveSpring(), + animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), label = "preflight_rotation" ) Card( modifier = modifier .fillMaxWidth() - .animateContentSize(animationSpec = MeshifyDesignSystem.Motion.expressiveSpring()), - shape = MeshifyDesignSystem.Shapes.CardMedium, + .animateContentSize(animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f)), + shape = MeshifyDesignSystem.Shapes.Card, colors = CardDefaults.cardColors( containerColor = when { isRunning -> MaterialTheme.colorScheme.surfaceContainerLow diff --git a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/RealDeviceTestScreen.kt b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/RealDeviceTestScreen.kt index ebbae210..27b0d372 100644 --- a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/RealDeviceTestScreen.kt +++ b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/RealDeviceTestScreen.kt @@ -232,7 +232,7 @@ private fun InitialStateContent(onRunPreflight: () -> Unit) { private fun RunningPreflightContent(state: RealDeviceTestingUiState.RunningPreflight) { TestingCard( modifier = Modifier.fillMaxWidth(), - shape = MeshifyDesignSystem.Shapes.CardMedium, + shape = MeshifyDesignSystem.Shapes.Card, containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { Column( @@ -561,7 +561,7 @@ private fun TestsDoneContent( @Composable private fun TestingCard( modifier: Modifier = Modifier, - shape: RoundedCornerShape = MeshifyDesignSystem.Shapes.CardMedium, + shape: RoundedCornerShape = MeshifyDesignSystem.Shapes.Card, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, content: @Composable () -> Unit ) { diff --git a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestProgressPanel.kt b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestProgressPanel.kt index 616e5c8f..ba7adb02 100644 --- a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestProgressPanel.kt +++ b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestProgressPanel.kt @@ -50,7 +50,7 @@ fun TestProgressPanel( ) { Card( modifier = modifier.fillMaxWidth(), - shape = MeshifyDesignSystem.Shapes.CardMedium, + shape = MeshifyDesignSystem.Shapes.Card, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ) diff --git a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestResultsPanel.kt b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestResultsPanel.kt index 587fc475..cd680fe4 100644 --- a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestResultsPanel.kt +++ b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestResultsPanel.kt @@ -1,6 +1,7 @@ package com.p2p.meshify.feature.realdevicetesting.ui import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -72,7 +73,7 @@ fun TestResultsPanel( Card( modifier = modifier.fillMaxWidth(), - shape = MeshifyDesignSystem.Shapes.CardMedium, + shape = MeshifyDesignSystem.Shapes.Card, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) @@ -251,14 +252,14 @@ private fun ResultItem( var expanded by remember { mutableStateOf(result.error != null) } val rotation by animateFloatAsState( targetValue = if (expanded) 90f else 0f, - animationSpec = MeshifyDesignSystem.Motion.expressiveSpring(), + animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), label = "result_rotation" ) Card( modifier = modifier .fillMaxWidth() - .animateContentSize(animationSpec = MeshifyDesignSystem.Motion.expressiveSpring()), + .animateContentSize(animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f)), shape = MeshifyDesignSystem.Shapes.CardSmall, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh diff --git a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestTypeSelector.kt b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestTypeSelector.kt index b062ec88..c9e6b1eb 100644 --- a/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestTypeSelector.kt +++ b/feature/real-device-testing/src/main/java/com/p2p/meshify/feature/realdevicetesting/ui/TestTypeSelector.kt @@ -47,7 +47,7 @@ fun TestTypeSelector( ) { Card( modifier = modifier.fillMaxWidth(), - shape = MeshifyDesignSystem.Shapes.CardMedium, + shape = MeshifyDesignSystem.Shapes.Card, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt index c3e50202..332d810d 100644 --- a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsScreen.kt @@ -3,6 +3,7 @@ package com.p2p.meshify.feature.settings import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.animateFloatAsState @@ -22,6 +23,7 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -37,13 +39,15 @@ import coil3.request.ImageRequest import coil3.request.crossfade import com.p2p.meshify.core.common.R import com.p2p.meshify.core.util.FileUtils -import com.p2p.meshify.domain.model.BubbleStyle -import com.p2p.meshify.domain.model.FontFamilyPreset -import com.p2p.meshify.domain.model.MotionPreset -import com.p2p.meshify.domain.model.ShapeStyle import com.p2p.meshify.domain.model.TransportMode import com.p2p.meshify.domain.repository.ThemeMode -import com.p2p.meshify.core.ui.components.* +import com.p2p.meshify.core.ui.components.MeshifySettingsGroup +import com.p2p.meshify.core.ui.components.MeshifySettingsItem +import com.p2p.meshify.core.ui.components.SeedColorPickerGrid +import com.p2p.meshify.core.ui.components.MeshifyTextInputDialog +import com.p2p.meshify.core.ui.components.ThemeSelectionBottomSheet +import com.p2p.meshify.core.ui.components.MeshifySelectionDialog +import com.p2p.meshify.core.ui.components.MeshifyAvatar import com.p2p.meshify.core.ui.hooks.HapticPattern import com.p2p.meshify.core.ui.hooks.LocalPremiumHaptics import com.p2p.meshify.core.ui.theme.MeshifyDesignSystem @@ -64,7 +68,7 @@ fun SettingsScreen( val haptics = LocalPremiumHaptics.current // Unified SettingsUiState — single collectAsState replacing 16+ individual flows - val state by viewModel.settingsUiState.collectAsState() + val state by viewModel.settingsUiState.collectAsStateWithLifecycle() val appVersion = viewModel.appVersion // Derived state from unified state @@ -73,16 +77,11 @@ fun SettingsScreen( // UI State for dialogs and bottom sheets var showNameDialog by remember { mutableStateOf(false) } var showThemeSheet by remember { mutableStateOf(false) } - var showMotionDialog by remember { mutableStateOf(false) } var showLanguageDialog by remember { mutableStateOf(false) } var showFontSizeDialog by remember { mutableStateOf(false) } var showBackupDialog by remember { mutableStateOf(false) } var showBleSheet by remember { mutableStateOf(false) } var showCreditsDialog by remember { mutableStateOf(false) } - var showShapeDialog by remember { mutableStateOf(false) } - var showMotionScaleDialog by remember { mutableStateOf(false) } - var showFontFamilyDialog by remember { mutableStateOf(false) } - var showBubbleDialog by remember { mutableStateOf(false) } var nameInput by remember { mutableStateOf(state.displayName) } var cacheStatus by remember { mutableStateOf(null) } var backupStatus by remember { mutableStateOf(null) } @@ -137,35 +136,16 @@ fun SettingsScreen( ) { Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Lg)) - // === EXPRESSIVE PULSE HEADER (Avatar) === - ExpressivePulseHeader( - size = 140.dp, - modifier = Modifier - .scale(animateFloatAsState(1f, spring(dampingRatio = 0.7f, stiffness = 350f)).value) - .clickable { - haptics.perform(HapticPattern.Pop) - imagePickerLauncher.launch("image/*") - } - ) { - if (avatarFile != null) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(avatarFile) - .crossfade(true) - .build(), - contentDescription = stringResource(R.string.settings_content_desc_avatar), - modifier = Modifier.fillMaxSize(), - contentScale = androidx.compose.ui.layout.ContentScale.Crop - ) - } else { - Icon( - imageVector = Icons.Default.Person, - contentDescription = stringResource(R.string.settings_avatar), - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary - ) + // Avatar + MeshifyAvatar( + avatarHash = state.avatarHash, + initials = state.displayName.take(2), + size = 120.dp, + modifier = Modifier.clickable { + haptics.perform(HapticPattern.Pop) + imagePickerLauncher.launch("image/*") } - } + ) Spacer(Modifier.height(MeshifyDesignSystem.Spacing.Md)) @@ -282,67 +262,6 @@ fun SettingsScreen( } } - // === SECTION 3: MESH ENGINE (MD3E) === - MeshifySettingsGroup(title = stringResource(R.string.settings_section_md3e_expressive)) { - // Motion Physics - val gentleLabel = stringResource(R.string.settings_motion_gentle_label) - val standardLabel = stringResource(R.string.settings_motion_standard_label) - val snappyLabel = stringResource(R.string.settings_motion_snappy_label) - val bouncyLabel = stringResource(R.string.settings_motion_bouncy_label) - MeshifySettingsItem( - title = stringResource(R.string.settings_motion_system), - subtitle = when (state.motionPreset) { - MotionPreset.GENTLE -> gentleLabel - MotionPreset.STANDARD -> standardLabel - MotionPreset.SNAPPY -> snappyLabel - MotionPreset.BOUNCY -> bouncyLabel - }, - icon = Icons.Default.Animation, - onClick = { showMotionDialog = true } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Shape Style - val sunnyLabel = stringResource(R.string.settings_shape_label_sunny) - val breezyLabel = stringResource(R.string.settings_shape_label_breezy) - val pentagonLabel = stringResource(R.string.settings_shape_label_pentagon) - val blobLabel = stringResource(R.string.settings_shape_label_blob) - val burstLabel = stringResource(R.string.settings_shape_label_burst) - val cloverLabel = stringResource(R.string.settings_shape_label_clover) - val circleLabel = stringResource(R.string.settings_shape_label_circle) - MeshifySettingsItem( - title = stringResource(R.string.settings_shape_style), - subtitle = when (state.shapeStyle) { - ShapeStyle.SUNNY -> sunnyLabel - ShapeStyle.BREEZY -> breezyLabel - ShapeStyle.PENTAGON -> pentagonLabel - ShapeStyle.BLOB -> blobLabel - ShapeStyle.BURST -> burstLabel - ShapeStyle.CLOVER -> cloverLabel - ShapeStyle.CIRCLE -> circleLabel - }, - icon = Icons.Default.Star, - onClick = { showShapeDialog = true } - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = MeshifyDesignSystem.Spacing.Md), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - - // Motion Scale - MeshifySettingsItem( - title = stringResource(R.string.settings_motion_scale), - subtitle = stringResource(R.string.settings_motion_scale_desc) + " (${state.motionScale}x)", - icon = Icons.Default.AspectRatio, - onClick = { showMotionScaleDialog = true } - ) - } - // === SECTION 4: PRIVACY & VISIBILITY === MeshifySettingsGroup(title = stringResource(R.string.settings_section_privacy)) { MeshifySettingsItem( @@ -636,40 +555,6 @@ fun SettingsScreen( ) } - // Motion Preset Selection Dialog - if (showMotionDialog) { - val gentleLabel = stringResource(R.string.settings_motion_gentle_label) - val standardLabel = stringResource(R.string.settings_motion_standard_label) - val snappyLabel = stringResource(R.string.settings_motion_snappy_label) - val bouncyLabel = stringResource(R.string.settings_motion_bouncy_label) - MeshifySelectionDialog( - title = stringResource(R.string.settings_dialog_motion_preset), - options = MotionPreset.entries, - selectedOption = state.motionPreset, - onOptionSelected = { - haptics.perform(HapticPattern.Pop) - viewModel.setMotionPreset(it) - }, - onDismiss = { showMotionDialog = false }, - optionLabel = { - when (it) { - MotionPreset.GENTLE -> gentleLabel - MotionPreset.STANDARD -> standardLabel - MotionPreset.SNAPPY -> snappyLabel - MotionPreset.BOUNCY -> bouncyLabel - } - }, - optionIcon = { - when (it) { - MotionPreset.GENTLE -> Icons.Default.SlowMotionVideo - MotionPreset.STANDARD -> Icons.Default.Speed - MotionPreset.SNAPPY -> Icons.Default.FastForward - MotionPreset.BOUNCY -> Icons.Default.TrendingUp - } - } - ) - } - // Language Selection Dialog if (showLanguageDialog) { val arabicLabel = stringResource(R.string.settings_language_arabic) @@ -681,6 +566,7 @@ fun SettingsScreen( onOptionSelected = { lang -> haptics.perform(HapticPattern.Pop) viewModel.setAppLanguage(lang) + (context as? ComponentActivity)?.recreate() showLanguageDialog = false }, onDismiss = { showLanguageDialog = false }, @@ -811,113 +697,6 @@ fun SettingsScreen( ) } - // Shape Style Dialog - if (showShapeDialog) { - val sunnyLabel = stringResource(R.string.settings_shape_label_sunny) - val breezyLabel = stringResource(R.string.settings_shape_label_breezy) - val pentagonLabel = stringResource(R.string.settings_shape_label_pentagon) - val blobLabel = stringResource(R.string.settings_shape_label_blob) - val burstLabel = stringResource(R.string.settings_shape_label_burst) - val cloverLabel = stringResource(R.string.settings_shape_label_clover) - val circleLabel = stringResource(R.string.settings_shape_label_circle) - MeshifySelectionDialog( - title = stringResource(R.string.settings_shape_style), - options = ShapeStyle.entries, - selectedOption = state.shapeStyle, - onOptionSelected = { - haptics.perform(HapticPattern.Pop) - viewModel.setShapeStyle(it) - }, - onDismiss = { showShapeDialog = false }, - optionLabel = { - when (it) { - ShapeStyle.SUNNY -> sunnyLabel - ShapeStyle.BREEZY -> breezyLabel - ShapeStyle.PENTAGON -> pentagonLabel - ShapeStyle.BLOB -> blobLabel - ShapeStyle.BURST -> burstLabel - ShapeStyle.CLOVER -> cloverLabel - ShapeStyle.CIRCLE -> circleLabel - } - }, - optionIcon = { Icons.Default.Star } - ) - } - - // Motion Scale Dialog - if (showMotionScaleDialog) { - val scaleOptions = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f) - MeshifySelectionDialog( - title = stringResource(R.string.settings_motion_scale), - options = scaleOptions, - selectedOption = state.motionScale.coerceIn(0.5f, 2.0f), - onOptionSelected = { - haptics.perform(HapticPattern.Pop) - viewModel.setMotionScale(it) - }, - onDismiss = { showMotionScaleDialog = false }, - optionLabel = { "${it}x" }, - optionIcon = { Icons.Default.AspectRatio } - ) - } - - // Font Family Dialog - if (showFontFamilyDialog) { - val robotoLabel = stringResource(R.string.settings_font_roboto) - val poppinsLabel = stringResource(R.string.settings_font_label_poppins) - val loraLabel = stringResource(R.string.settings_font_label_lora) - val montserratLabel = stringResource(R.string.settings_font_label_montserrat) - val playfairLabel = stringResource(R.string.settings_font_label_playfair) - val interLabel = stringResource(R.string.settings_font_label_inter) - MeshifySelectionDialog( - title = stringResource(R.string.settings_font_family), - options = FontFamilyPreset.entries, - selectedOption = state.fontFamilyPreset, - onOptionSelected = { - haptics.perform(HapticPattern.Pop) - viewModel.setFontFamilyPreset(it) - }, - onDismiss = { showFontFamilyDialog = false }, - optionLabel = { - when (it) { - FontFamilyPreset.ROBOTO -> robotoLabel - FontFamilyPreset.POPPINS -> poppinsLabel - FontFamilyPreset.LORA -> loraLabel - FontFamilyPreset.MONTSERRAT -> montserratLabel - FontFamilyPreset.PLAYFAIR -> playfairLabel - FontFamilyPreset.INTER -> interLabel - } - }, - optionIcon = { Icons.Default.TextFields } - ) - } - - // Bubble Style Dialog - if (showBubbleDialog) { - val roundedLabel = stringResource(R.string.settings_bubble_rounded) - val tailedLabel = stringResource(R.string.settings_bubble_label_tailed) - val squarclesLabel = stringResource(R.string.settings_bubble_label_squarcles) - val organicLabel = stringResource(R.string.settings_bubble_label_organic) - MeshifySelectionDialog( - title = stringResource(R.string.settings_bubble_style), - options = BubbleStyle.entries, - selectedOption = state.bubbleStyle, - onOptionSelected = { - haptics.perform(HapticPattern.Pop) - viewModel.setBubbleStyle(it) - }, - onDismiss = { showBubbleDialog = false }, - optionLabel = { - when (it) { - BubbleStyle.ROUNDED -> roundedLabel - BubbleStyle.TAILED -> tailedLabel - BubbleStyle.SQUARCLES -> squarclesLabel - BubbleStyle.ORGANIC -> organicLabel - } - }, - optionIcon = { Icons.Default.ChatBubble } - ) - } } /** diff --git a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt index 0ea880f9..dea55bad 100644 --- a/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/p2p/meshify/feature/settings/SettingsViewModel.kt @@ -6,10 +6,6 @@ import androidx.lifecycle.viewModelScope import android.content.Context import android.net.Uri import com.p2p.meshify.core.util.FileUtils -import com.p2p.meshify.domain.model.BubbleStyle -import com.p2p.meshify.domain.model.FontFamilyPreset -import com.p2p.meshify.domain.model.MotionPreset -import com.p2p.meshify.domain.model.ShapeStyle import com.p2p.meshify.domain.model.TransportMode import com.p2p.meshify.domain.repository.ISettingsRepository import com.p2p.meshify.domain.repository.ThemeMode @@ -21,11 +17,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Unified UI state for the Settings screen. - * Combines all individual settings into a single data class - * to enable single-collect StateFlow and reduce recompositions. - */ data class SettingsUiState( val displayName: String = "", val themeMode: ThemeMode = ThemeMode.SYSTEM, @@ -35,13 +26,6 @@ data class SettingsUiState( val avatarHash: String? = null, val deviceId: String = "", val deviceIdLoaded: Boolean = false, - val appVersion: String = "", - val motionPreset: MotionPreset = MotionPreset.STANDARD, - val motionScale: Float = 1.0f, - val fontFamilyPreset: FontFamilyPreset = FontFamilyPreset.ROBOTO, - val customFontUri: String? = null, - val bubbleStyle: BubbleStyle = BubbleStyle.ROUNDED, - val visualDensity: Float = 1.0f, val seedColor: Int = 0xFF006D68.toInt(), val appLanguage: String = "en", val fontSizeScale: Float = 1.0f, @@ -50,60 +34,40 @@ data class SettingsUiState( val notificationVibrate: Boolean = true, val bleEnabled: Boolean = false, val transportMode: TransportMode = TransportMode.AUTO, - val displayNameError: String? = null, - val shapeStyle: ShapeStyle = ShapeStyle.CIRCLE + val displayNameError: String? = null ) -/** - * ViewModel for application settings with Type-Safe Enums. - * Extended for MD3E - Full Control Plan. - * - * Uses a single [SettingsUiState] StateFlow instead of 20+ individual flows. - * This reduces recompositions from 16+ to exactly 1 when any setting changes. - */ class SettingsViewModel @Inject constructor( val settingsRepository: ISettingsRepository ) : ViewModel() { - // Unified SettingsUiState — single StateFlow replacing 20 individual flows - // Uses MutableStateFlow updated by individual collectors for type safety - // Note: deviceId is loaded asynchronously; empty string is a brief placeholder private val _settingsUiState = MutableStateFlow(SettingsUiState()) val settingsUiState: StateFlow = _settingsUiState init { - // Collect each repository flow and update the unified state - settingsRepository.displayName.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(displayName = value) }.launchIn(viewModelScope) - settingsRepository.themeMode.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(themeMode = value) }.launchIn(viewModelScope) - settingsRepository.dynamicColorEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(dynamicColorEnabled = value) }.launchIn(viewModelScope) - settingsRepository.hapticFeedbackEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(hapticFeedbackEnabled = value) }.launchIn(viewModelScope) - settingsRepository.isNetworkVisible.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(isNetworkVisible = value) }.launchIn(viewModelScope) - settingsRepository.avatarHash.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(avatarHash = value) }.launchIn(viewModelScope) - settingsRepository.motionPreset.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(motionPreset = value) }.launchIn(viewModelScope) - settingsRepository.motionScale.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(motionScale = value) }.launchIn(viewModelScope) - settingsRepository.fontFamilyPreset.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(fontFamilyPreset = value) }.launchIn(viewModelScope) - settingsRepository.customFontUri.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(customFontUri = value) }.launchIn(viewModelScope) - settingsRepository.bubbleStyle.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(bubbleStyle = value) }.launchIn(viewModelScope) - settingsRepository.visualDensity.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(visualDensity = value) }.launchIn(viewModelScope) - settingsRepository.seedColor.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(seedColor = value) }.launchIn(viewModelScope) - settingsRepository.appLanguage.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(appLanguage = value) }.launchIn(viewModelScope) - settingsRepository.fontSizeScale.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(fontSizeScale = value) }.launchIn(viewModelScope) - settingsRepository.notificationsEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(notificationsEnabled = value) }.launchIn(viewModelScope) - settingsRepository.notificationSound.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(notificationSound = value) }.launchIn(viewModelScope) - settingsRepository.notificationVibrate.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(notificationVibrate = value) }.launchIn(viewModelScope) - settingsRepository.bleEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(bleEnabled = value) }.launchIn(viewModelScope) - settingsRepository.transportMode.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(transportMode = value) }.launchIn(viewModelScope) - settingsRepository.shapeStyle.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(shapeStyle = value) }.launchIn(viewModelScope) - - // Load deviceId asynchronously and update state when ready - viewModelScope.launch { - val deviceId = settingsRepository.getDeviceId() + val repo = settingsRepository + repo.displayName.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(displayName = value) }.launchIn(viewModelScope) + repo.themeMode.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(themeMode = value) }.launchIn(viewModelScope) + repo.dynamicColorEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(dynamicColorEnabled = value) }.launchIn(viewModelScope) + repo.hapticFeedbackEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(hapticFeedbackEnabled = value) }.launchIn(viewModelScope) + repo.isNetworkVisible.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(isNetworkVisible = value) }.launchIn(viewModelScope) + repo.avatarHash.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(avatarHash = value) }.launchIn(viewModelScope) + repo.seedColor.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(seedColor = value) }.launchIn(viewModelScope) + repo.appLanguage.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(appLanguage = value) }.launchIn(viewModelScope) + repo.fontSizeScale.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(fontSizeScale = value) }.launchIn(viewModelScope) + repo.notificationsEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(notificationsEnabled = value) }.launchIn(viewModelScope) + repo.notificationSound.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(notificationSound = value) }.launchIn(viewModelScope) + repo.notificationVibrate.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(notificationVibrate = value) }.launchIn(viewModelScope) + repo.bleEnabled.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(bleEnabled = value) }.launchIn(viewModelScope) + repo.transportMode.onEach { value -> _settingsUiState.value = _settingsUiState.value.copy(transportMode = value) }.launchIn(viewModelScope) + + viewModelScope.launch { + val deviceId = repo.getDeviceId() _settingsUiState.value = _settingsUiState.value.copy(deviceId = deviceId, deviceIdLoaded = true) _deviceId.value = deviceId } } - // deviceId is also exposed as a separate flow for backward compatibility private val _deviceId = MutableStateFlow("") val deviceId: StateFlow = _deviceId @@ -121,45 +85,27 @@ class SettingsViewModel @Inject constructor( } fun setThemeMode(mode: ThemeMode) { - viewModelScope.launch { - settingsRepository.setThemeMode(mode) - } + viewModelScope.launch { settingsRepository.setThemeMode(mode) } } fun setHapticFeedback(enabled: Boolean) { - viewModelScope.launch { - settingsRepository.setHapticFeedback(enabled) - } + viewModelScope.launch { settingsRepository.setHapticFeedback(enabled) } } fun setDynamicColor(enabled: Boolean) { - viewModelScope.launch { - settingsRepository.setDynamicColor(enabled) - } + viewModelScope.launch { settingsRepository.setDynamicColor(enabled) } } fun setNetworkVisibility(visible: Boolean) { - viewModelScope.launch { - settingsRepository.setNetworkVisibility(visible) - } + viewModelScope.launch { settingsRepository.setNetworkVisibility(visible) } } - /** - * Updates the user's avatar by picking a file, hashing it, and saving it locally. - * Content-addressable storage ensures no duplicate transfers. - */ fun updateAvatar(context: Context, uri: Uri) { viewModelScope.launch { val bytes = FileUtils.getBytesFromUri(context, uri) if (bytes != null) { val hash = FileUtils.calculateHash(bytes) - // Save to internal storage using hash as filename - val savedPath = FileUtils.saveBytesToInternalStorage( - context = context, - fileName = hash, - data = bytes, - category = "avatars" - ) + val savedPath = FileUtils.saveBytesToInternalStorage(context, hash, bytes, "avatars") if (savedPath != null) { settingsRepository.updateAvatarHash(hash) } @@ -167,48 +113,6 @@ class SettingsViewModel @Inject constructor( } } - fun setMotionPreset(preset: MotionPreset) { - viewModelScope.launch { - settingsRepository.setMotionPreset(preset) - } - } - - fun setShapeStyle(style: ShapeStyle) { - viewModelScope.launch { - settingsRepository.setShapeStyle(style) - } - } - - fun setMotionScale(scale: Float) { - viewModelScope.launch { - settingsRepository.setMotionScale(scale) - } - } - - fun setFontFamilyPreset(family: FontFamilyPreset) { - viewModelScope.launch { - settingsRepository.setFontFamilyPreset(family) - } - } - - fun setCustomFontUri(uri: String?) { - viewModelScope.launch { - settingsRepository.setCustomFontUri(uri) - } - } - - fun setBubbleStyle(style: BubbleStyle) { - viewModelScope.launch { - settingsRepository.setBubbleStyle(style) - } - } - - fun setVisualDensity(density: Float) { - viewModelScope.launch { - settingsRepository.setVisualDensity(density) - } - } - fun setSeedColor(color: Color) { viewModelScope.launch { val colorInt = android.graphics.Color.argb( @@ -221,48 +125,32 @@ class SettingsViewModel @Inject constructor( } } - // New Settings Functions fun setAppLanguage(language: String) { - viewModelScope.launch { - settingsRepository.setAppLanguage(language) - } + viewModelScope.launch { settingsRepository.setAppLanguage(language) } } fun setFontSizeScale(scale: Float) { - viewModelScope.launch { - settingsRepository.setFontSizeScale(scale) - } + viewModelScope.launch { settingsRepository.setFontSizeScale(scale) } } fun setNotificationsEnabled(enabled: Boolean) { - viewModelScope.launch { - settingsRepository.setNotificationsEnabled(enabled) - } + viewModelScope.launch { settingsRepository.setNotificationsEnabled(enabled) } } fun setNotificationSound(enabled: Boolean) { - viewModelScope.launch { - settingsRepository.setNotificationSound(enabled) - } + viewModelScope.launch { settingsRepository.setNotificationSound(enabled) } } fun setNotificationVibrate(enabled: Boolean) { - viewModelScope.launch { - settingsRepository.setNotificationVibrate(enabled) - } + viewModelScope.launch { settingsRepository.setNotificationVibrate(enabled) } } - // BLE Transport Mutators fun setBleEnabled(enabled: Boolean) { - viewModelScope.launch { - settingsRepository.setBleEnabled(enabled) - } + viewModelScope.launch { settingsRepository.setBleEnabled(enabled) } } fun setTransportMode(mode: TransportMode) { - viewModelScope.launch { - settingsRepository.setTransportMode(mode) - } + viewModelScope.launch { settingsRepository.setTransportMode(mode) } } fun clearCache(onResult: (Result) -> Unit) { @@ -278,15 +166,8 @@ class SettingsViewModel @Inject constructor( fun exportBackup(onResult: (Result) -> Unit) { viewModelScope.launch { - val result = settingsRepository.exportBackup() - onResult(result) + onResult(settingsRepository.exportBackup()) } } - fun importBackup(backupJson: String, onResult: (Result) -> Unit) { - viewModelScope.launch { - val result = settingsRepository.importBackup(backupJson) - onResult(result) - } - } }