diff --git a/.gitignore b/.gitignore index ed72dc62..562770f8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ captures/ .cxx/ .kotlin/ +# ProGuard/R8 mapping files +mapping.txt +**/mapping.txt + # VS Code .vscode/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 18f39b50..e9bc8960 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) - // alias(libs.plugins.baseline.profile) + alias(libs.plugins.baseline.profile) // Code Quality plugins alias(libs.plugins.ktlint) alias(libs.plugins.detekt) @@ -184,6 +184,7 @@ dependencies { // Compose BOM and UI implementation(platform(libs.compose.bom)) implementation(libs.bundles.compose.ui) + implementation(libs.compose.material.icons.extended) // Hilt Dependency Injection implementation(libs.hilt.android) @@ -211,8 +212,8 @@ dependencies { implementation(libs.media3.ui) implementation(libs.media3.common) - // Baseline Profile dependency - commented out for now - // baselineProfile(project(":baselineprofile")) + // Baseline Profile dependency for AOT compilation optimization + baselineProfile(project(":baselineprofile")) // Testing testImplementation(libs.bundles.testing) diff --git a/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/animations/Animations.kt b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/animations/Animations.kt new file mode 100644 index 00000000..cf7c2649 --- /dev/null +++ b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/animations/Animations.kt @@ -0,0 +1,85 @@ +package com.heartlessveteran.myriad.core.ui.animations + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically + +/** + * Standard animation durations following Material Design guidelines + */ +object AnimationDurations { + const val FAST = 150 + const val NORMAL = 300 + const val SLOW = 500 +} + +/** + * Common enter transitions + */ +object EnterTransitions { + val fadeIn = fadeIn(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideInFromRight = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeIn(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideInFromLeft = slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeIn(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideInFromBottom = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeIn(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideInFromTop = slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeIn(animationSpec = tween(AnimationDurations.NORMAL)) +} + +/** + * Common exit transitions + */ +object ExitTransitions { + val fadeOut = fadeOut(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideOutToLeft = slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeOut(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideOutToRight = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeOut(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideOutToTop = slideOutVertically( + targetOffsetY = { -it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeOut(animationSpec = tween(AnimationDurations.NORMAL)) + + val slideOutToBottom = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(AnimationDurations.NORMAL) + ) + fadeOut(animationSpec = tween(AnimationDurations.NORMAL)) +} + +/** + * Predefined enter and exit transition combinations + */ +object TransitionPairs { + val horizontalSlideRight = EnterTransitions.slideInFromRight to ExitTransitions.slideOutToLeft + val horizontalSlideLeft = EnterTransitions.slideInFromLeft to ExitTransitions.slideOutToRight + val verticalSlideUp = EnterTransitions.slideInFromBottom to ExitTransitions.slideOutToTop + val verticalSlideDown = EnterTransitions.slideInFromTop to ExitTransitions.slideOutToBottom + val fade = EnterTransitions.fadeIn to ExitTransitions.fadeOut +} diff --git a/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/haptics/HapticFeedback.kt b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/haptics/HapticFeedback.kt new file mode 100644 index 00000000..4a36843d --- /dev/null +++ b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/haptics/HapticFeedback.kt @@ -0,0 +1,73 @@ +package com.heartlessveteran.myriad.core.ui.haptics + +import android.os.Build +import android.view.HapticFeedbackConstants +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView + +/** + * Haptic feedback utility for providing tactile feedback to users + * following Material Design guidelines for appropriate feedback + */ +class HapticFeedbackHelper(private val view: View) { + + /** + * Light click feedback for buttons and tappable items. + * Uses KEYBOARD_TAP on API 27+ (recommended), falls back to CLOCK_TICK on older devices. + */ + fun performLightClick() { + val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + HapticFeedbackConstants.KEYBOARD_TAP + } else { + @Suppress("DEPRECATION") + HapticFeedbackConstants.CLOCK_TICK + } + view.performHapticFeedback(constant) + } + + /** + * Standard click feedback for primary actions + */ + fun performClick() { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + + /** + * Long press feedback for context menus and long-press actions + */ + fun performLongPress() { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + /** + * Confirmation feedback for successful actions (e.g., save, submit). + * Requires API 30+, falls back to VIRTUAL_KEY on older devices. + */ + fun performConfirm() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + } else { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + + /** + * Rejection feedback for errors or cancelled actions. + * Requires API 30+, falls back to VIRTUAL_KEY on older devices. + */ + fun performReject() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(HapticFeedbackConstants.REJECT) + } else { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } +} + +@Composable +fun rememberHapticFeedback(): HapticFeedbackHelper { + val view = LocalView.current + return remember(view) { HapticFeedbackHelper(view) } +} diff --git a/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/image/ImageLoading.kt b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/image/ImageLoading.kt new file mode 100644 index 00000000..8045be2f --- /dev/null +++ b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/image/ImageLoading.kt @@ -0,0 +1,76 @@ +package com.heartlessveteran.myriad.core.ui.image + +import android.content.Context +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.CachePolicy +import coil.util.DebugLogger +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +object OptimizedImageLoading { + + /** + * The percentage of available app memory allocated for image memory cache. + * 20% is chosen as a balance between performance and memory usage on typical Android devices. + * This value was selected based on profiling with common manga/anime image sizes, + * and is in line with Coil's recommendations for most use cases. + * Adjust if profiling shows excessive memory pressure or cache misses. + */ + private const val MEMORY_CACHE_PERCENT = 0.20 + + /** + * The maximum disk cache size for images, in bytes. + * 200MB is selected to allow caching of hundreds of manga/anime pages and covers, + * while avoiding excessive storage usage on devices with limited space. + * This value should be revisited if profiling shows frequent cache evictions or if + * device storage constraints change. + */ + private const val DISK_CACHE_SIZE_BYTES = 200L * 1024 * 1024 + + /** + * Network timeout duration in seconds for image loading operations. + * 30 seconds provides a reasonable balance between patience for slow connections + * and preventing indefinite hangs. + */ + private const val NETWORK_TIMEOUT_SECONDS = 30L + + fun createOptimizedImageLoader( + context: Context, + enableDebugLogs: Boolean = false + ): ImageLoader { + return ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(MEMORY_CACHE_PERCENT) + .weakReferencesEnabled(true) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image_cache")) + .maxSizeBytes(DISK_CACHE_SIZE_BYTES) + .build() + } + .okHttpClient { + OkHttpClient.Builder() + .connectTimeout(NETWORK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(NETWORK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(NETWORK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } + .respectCacheHeaders(true) + .crossfade(true) + .apply { + if (enableDebugLogs) { + logger(DebugLogger()) + } + } + .build() + } + + val coverImageCachePolicy = CachePolicy.ENABLED + val pageImageCachePolicy = CachePolicy.ENABLED + val thumbnailCachePolicy = CachePolicy.ENABLED +} diff --git a/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/layouts/AdaptiveLayouts.kt b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/layouts/AdaptiveLayouts.kt new file mode 100644 index 00000000..0d91d7b9 --- /dev/null +++ b/core/ui/src/main/kotlin/com/heartlessveteran/myriad/core/ui/layouts/AdaptiveLayouts.kt @@ -0,0 +1,67 @@ +package com.heartlessveteran.myriad.core.ui.layouts + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Material Design 3 breakpoint for compact window width (phones in portrait). + * Screens narrower than this value are considered COMPACT. + */ +private const val COMPACT_WIDTH_DP = 600 + +/** + * Material Design 3 breakpoint for medium window width (tablets, phones in landscape). + * Screens wider than COMPACT but narrower than this value are considered MEDIUM. + */ +private const val MEDIUM_WIDTH_DP = 840 + +enum class WindowSize { + COMPACT, + MEDIUM, + EXPANDED +} + +@Composable +fun rememberWindowSize(): WindowSize { + val configuration = LocalConfiguration.current + val screenWidthDp = configuration.screenWidthDp + + return when { + screenWidthDp < COMPACT_WIDTH_DP -> WindowSize.COMPACT + screenWidthDp < MEDIUM_WIDTH_DP -> WindowSize.MEDIUM + else -> WindowSize.EXPANDED + } +} + +object AdaptiveGrid { + fun columns(windowSize: WindowSize): Int = when (windowSize) { + WindowSize.COMPACT -> 2 + WindowSize.MEDIUM -> 3 + WindowSize.EXPANDED -> 4 + } + + fun spacing(windowSize: WindowSize): Dp = when (windowSize) { + WindowSize.COMPACT -> 8.dp + WindowSize.MEDIUM -> 12.dp + WindowSize.EXPANDED -> 16.dp + } + + fun padding(windowSize: WindowSize): Dp = when (windowSize) { + WindowSize.COMPACT -> 16.dp + WindowSize.MEDIUM -> 24.dp + WindowSize.EXPANDED -> 32.dp + } +} + +@Composable +fun shouldUseTwoPane(): Boolean { + return rememberWindowSize() == WindowSize.EXPANDED +} + +@Composable +fun shouldUseTwoPageReader(): Boolean { + val windowSize = rememberWindowSize() + return windowSize == WindowSize.MEDIUM || windowSize == WindowSize.EXPANDED +} diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md new file mode 100644 index 00000000..42c21376 --- /dev/null +++ b/docs/MODULE_ARCHITECTURE.md @@ -0,0 +1,44 @@ +# Project Myriad - Module Architecture + +## Overview +Project Myriad follows a multi-module architecture based on Clean Architecture principles. + +## Module Structure + +### Core Modules +- `:core:domain` - Business logic and entities (no Android dependencies) +- `:core:data` - Data layer with Room database and Retrofit +- `:core:ui` - Shared UI components, theme, animations, haptics + +### Feature Modules +- `:feature:vault` - Local media management +- `:feature:browser` - Online content discovery +- `:feature:reader` - Manga/comic reader +- `:feature:ai` - AI-powered features +- `:feature:settings` - App settings + +### Performance Module +- `:baselineprofile` - AOT compilation profiles + +### App Module +- `:app` - Main application module + +## Dependency Graph +``` +:app +├─ :core:ui +│ └─ :core:domain +├─ :core:data +│ └─ :core:domain +├─ :feature:vault +├─ :feature:browser +├─ :feature:reader +├─ :feature:ai +└─ :feature:settings +``` + +## Best Practices +1. Features depend on core modules, never the reverse +2. Use version catalog for all dependencies +3. Each module has clear, single responsibility +4. Minimize public API surface of each module diff --git a/feature/browser/build.gradle.kts b/feature/browser/build.gradle.kts index 3ab66a3d..d04fc4a5 100644 --- a/feature/browser/build.gradle.kts +++ b/feature/browser/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") // Lifecycle and ViewModel implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")