Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ captures/
.cxx/
.kotlin/

# ProGuard/R8 mapping files
mapping.txt
**/mapping.txt

# VS Code
.vscode/

Expand Down
7 changes: 4 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider making animation durations customizable.

Configurable durations would better support users with accessibility needs, such as those preferring reduced motion.

Suggested implementation:

/**
 * Standard animation durations following Material Design guidelines.
 * Durations are configurable to support accessibility needs.
 */
data class AnimationDurations(
    val fast: Int = 150,
    val normal: Int = 300,
    val slow: Int = 500
)

/**
 * Default animation durations instance.
 * Use this unless you need to customize for accessibility.
 */
val DefaultAnimationDurations = AnimationDurations()

/**
 * Common enter transitions.
 * Accepts an AnimationDurations instance for customization.
 */
class EnterTransitions(
    private val durations: AnimationDurations = DefaultAnimationDurations
) {
    val fadeIn = fadeIn(animationSpec = tween(durations.normal))

    val slideInFromRight = slideInHorizontally(
        animationSpec = tween(durations.normal)
    )
    // Add other transitions here, using durations as needed
}

// For convenience, provide a default instance
val DefaultEnterTransitions = EnterTransitions()

  • Update all usages of AnimationDurations.NORMAL, AnimationDurations.FAST, etc. throughout your codebase to use an instance of AnimationDurations (e.g., durations.normal).
  • Update usages of EnterTransitions.fadeIn to use DefaultEnterTransitions.fadeIn or create a custom EnterTransitions with your desired durations.
  • If you have other transition objects (e.g., ExitTransitions), apply the same pattern for configurability.

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
}
Original file line number Diff line number Diff line change
@@ -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) }
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider supporting orientation changes and multi-window scenarios.

LocalConfiguration.current may not provide accurate screen width in multi-window or split-screen modes. Use WindowMetrics or equivalent APIs for precise sizing.

Suggested implementation:

import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.window.layout.WindowMetricsCalculator

enum class WindowSize {
    COMPACT,
    MEDIUM,
    EXPANDED
}

@Composable
fun rememberWindowSize(): WindowSize {
    val context = LocalContext.current
    val windowMetrics = remember {
        WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
    }
    val screenWidthDp = with(context.resources.displayMetrics) {
        windowMetrics.bounds.width() / density
    }

    return when {
        screenWidthDp < 600 -> WindowSize.COMPACT
        screenWidthDp < 840 -> WindowSize.MEDIUM
        else -> WindowSize.EXPANDED
    }
}

  • Make sure you have the androidx.window:window dependency in your build.gradle file.
  • If you want to update the window size reactively (on orientation or window size change), consider using a LaunchedEffect or DisposableEffect to recalculate metrics when configuration changes.
  • If your minSdk is below 30, ensure you use the correct WindowMetricsCalculator API for compatibility.

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
}
44 changes: 44 additions & 0 deletions docs/MODULE_ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions feature/browser/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading