Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import com.ahmadkharfan.androidstudiolite.feature.blockingerror.BlockingErrorRou
import com.ahmadkharfan.androidstudiolite.feature.blockingerror.BlockingErrorType
import com.ahmadkharfan.androidstudiolite.feature.crashreport.CrashReportRoute
import com.ahmadkharfan.androidstudiolite.feature.editor.api.EditorFeatureApi
import com.ahmadkharfan.androidstudiolite.feature.editor.api.EditorRoutes
import com.ahmadkharfan.androidstudiolite.feature.onboarding.api.OnboardingFeatureApi
import com.ahmadkharfan.androidstudiolite.feature.onboarding.api.OnboardingRoutes
import com.ahmadkharfan.androidstudiolite.feature.projects.api.ProjectsFeatureApi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class AslModuleBoundariesConventionPlugin : Plugin<Project> {
// evaluated, and only Strings are stored, which keeps it configuration-cache safe.
edges.set(target.provider { target.collectProjectEdges() })
contractSources.set(target.provider { target.collectContractSources() })
externalEdges.set(target.provider { target.collectExternalEdges() })
}
}

Expand All @@ -57,6 +58,18 @@ class AslModuleBoundariesConventionPlugin : Plugin<Project> {
}
.toMap()

/** External dependencies as `owner|configuration|group|name`, Strings only for the cache. */
private fun Project.collectExternalEdges(): List<String> = allprojects
.flatMap { project ->
project.configurations.flatMap { configuration ->
configuration.dependencies
.filter { it !is ProjectDependency && it.group != null }
.map { "${project.path}|${configuration.name}|${it.group}|${it.name}" }
}
}
.distinct()
.sorted()

private fun Project.collectProjectEdges(): List<String> = allprojects
.flatMap { project ->
project.configurations.flatMap { configuration ->
Expand Down Expand Up @@ -85,6 +98,9 @@ abstract class VerifyModuleBoundariesTask : DefaultTask() {
@get:Input
abstract val contractSources: MapProperty<String, String>

@get:Input
abstract val externalEdges: ListProperty<String>

@get:OutputFile
abstract val report: RegularFileProperty

Expand Down Expand Up @@ -118,6 +134,22 @@ abstract class VerifyModuleBoundariesTask : DefaultTask() {
staleBaseline.sorted().forEach { logger.lifecycle(" $it") }
}

val materialViolations = findMaterialViolations(
externalEdges.get().map { encoded ->
val (from, configuration, group, name) = encoded.split("|", limit = 4)
ExternalEdge(from = from, configuration = configuration, group = group, name = name)
},
)
if (materialViolations.isNotEmpty()) {
throw GradleException(
buildString {
appendLine("Material used directly by a feature (${materialViolations.size}):")
appendLine()
materialViolations.forEach { appendLine(it.render()) }
},
)
}

val contractViolations = findApiContractViolations(contractSources.get())
if (contractViolations.isNotEmpty()) {
throw GradleException(
Expand Down
34 changes: 33 additions & 1 deletion build-logic/conventions/src/main/kotlin/ModuleBoundaryRules.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
* A [ModuleEdge] is one declared project-to-project dependency. The Gradle plugin collects the edges
* and hands them here; everything about *whether an edge is allowed* lives in this file.
*/
/** An external (non-project) dependency declared by a module. */
data class ExternalEdge(
val from: String,
val configuration: String,
val group: String,
val name: String,
)

data class ModuleEdge(
val from: String,
val configuration: String,
Expand All @@ -25,8 +33,12 @@ data class BoundaryViolation(
* feature may legitimately exercise a real data implementation from its own tests.
*/
fun isProductionConfiguration(name: String): Boolean {
// Matched on the camelCase name, not lowercased: Gradle/AGP name test configurations either
// `test...` or `...Test...` (testImplementation, androidTestApi, debugUnitTestRuntimeOnly).
// Lowercasing first would also exempt a configuration that merely contains the letters "test",
// such as `contestImplementation`, leaving an unguarded production configuration.
if (name.startsWith("test") || name.contains("Test")) return false
val lower = name.lowercase()
if ("test" in lower) return false
val toolingPrefixes = listOf("ksp", "kapt", "detekt", "lint", "annotationprocessor", "compiler")
return toolingPrefixes.none { lower.startsWith(it) }
}
Expand Down Expand Up @@ -88,3 +100,23 @@ private fun violationFor(edge: ModuleEdge): BoundaryViolation? = when {
/** Edges that are allowed for now and expected to be removed. Nothing may be added to this list. */
val MODULE_BOUNDARY_BASELINE: Set<String> = setOf(
)

/**
* Features must render through the design system, never Material directly.
*
* The design system owns the Material dependency so the app's look is defined in one module: a
* feature reaching for `material3.Text` or `Scaffold` bypasses every token and quietly reintroduces
* Material defaults. `:designsystem` itself is exempt — wrapping Material is its job.
*/
fun findMaterialViolations(edges: List<ExternalEdge>): List<BoundaryViolation> = edges
.filter { isProductionConfiguration(it.configuration) }
.filter { isFeature(it.from) }
.filter { it.group == "androidx.compose.material3" || it.group == "androidx.compose.material" }
.map {
BoundaryViolation(
ModuleEdge(it.from, it.configuration, "${it.group}:${it.name}"),
"no-material-in-features",
"Features must depend on :designsystem instead. If a component is missing, add it there " +
"so every feature gets the same tokens.",
)
}
53 changes: 53 additions & 0 deletions build-logic/conventions/src/test/kotlin/ModuleBoundaryRulesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,56 @@ class ModuleBoundaryRulesTest {
)
}
}

class MaterialAndConfigurationRulesTest {

@Test
fun `a feature depending on material is a violation`() {
val found = findMaterialViolations(
listOf(ExternalEdge(":feature:a:presentation", "implementation", "androidx.compose.material3", "material3")),
)
assertEquals(1, found.size)
assertEquals("no-material-in-features", found.single().rule)
}

@Test
fun `the design system may depend on material`() {
// Wrapping Material is precisely its job.
assertTrue(
findMaterialViolations(
listOf(ExternalEdge(":designsystem", "implementation", "androidx.compose.material3", "material3")),
).isEmpty(),
)
}

@Test
fun `a feature may depend on non-material libraries`() {
assertTrue(
findMaterialViolations(
listOf(ExternalEdge(":feature:a:presentation", "implementation", "androidx.compose.ui", "ui")),
).isEmpty(),
)
}

@Test
fun `a feature may use material from a test configuration`() {
assertTrue(
findMaterialViolations(
listOf(ExternalEdge(":feature:a:presentation", "testImplementation", "androidx.compose.material3", "m3")),
).isEmpty(),
)
}

@Test
fun `real gradle test configuration names are exempt`() {
for (name in listOf("testImplementation", "androidTestApi", "debugUnitTestRuntimeOnly", "testDebugImplementation")) {
assertFalse(isProductionConfiguration(name), "$name should be exempt")
}
}

@Test
fun `a configuration merely containing the letters test is not exempt`() {
// `contest...` must not read as a test configuration and slip past every rule.
assertTrue(isProductionConfiguration("contestImplementation"))
}
}
2 changes: 2 additions & 0 deletions designsystem/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>LongParameterList:AslText.kt$( text: String, modifier: Modifier = Modifier, color: Color = Color.Unspecified, style: TextStyle = LocalTextStyle.current, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, textAlign: TextAlign? = null, softWrap: Boolean = true, fontWeight: FontWeight? = null, fontSize: TextUnit = TextUnit.Unspecified, fontFamily: FontFamily? = null, lineHeight: TextUnit = TextUnit.Unspecified, letterSpacing: TextUnit = TextUnit.Unspecified, )</ID>
<ID>LongParameterList:AslScaffold.kt$( modifier: Modifier = Modifier, topBar: @Composable () -&gt; Unit = {}, bottomBar: @Composable () -&gt; Unit = {}, floatingActionButton: @Composable () -&gt; Unit = {}, snackbarHost: @Composable () -&gt; Unit = {}, containerColor: Color = AslTheme.colors.surface, contentColor: Color = AslTheme.colors.textPrimary, contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, content: @Composable (PaddingValues) -&gt; Unit, )</ID>
<ID>CyclomaticComplexMethod:ApiKeyCard.kt$@Composable fun AslApiKeyCard( provider: String, value: String, onValueChange: (String) -&gt; Unit, modifier: Modifier = Modifier, providerIcon: String = "sparkles", description: String? = null, placeholder: String = "sk-…", status: AslApiKeyStatus = AslApiKeyStatus.None, errorMessage: String? = null, onTest: (String) -&gt; Unit = {}, testing: Boolean = false, onCollapse: (() -&gt; Unit)? = null, )</ID>
<ID>CyclomaticComplexMethod:AslMarkdownText.kt$@Composable fun AslMarkdownText( markdown: String, modifier: Modifier = Modifier, onCopyCode: (String) -&gt; Unit = {}, )</ID>
<ID>CyclomaticComplexMethod:BottomToolPanel.kt$@Composable fun AslBottomToolPanel( tabs: List&lt;AslBottomPanelTab&gt;, activeId: String?, modifier: Modifier = Modifier, contentHeight: Dp = 0.dp, defaultContentHeight: Dp = 260.dp, onContentHeightChange: (Dp) -&gt; Unit = {}, onSelect: (String) -&gt; Unit = {}, onToggle: () -&gt; Unit = {}, content: @Composable () -&gt; Unit = {}, )</ID>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.ahmadkharfan.androidstudiolite.designsystem.component.content

import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.ahmadkharfan.androidstudiolite.designsystem.theme.AslTheme

/** Divider defaulting to the design system's border token rather than Material's outline. */
@Composable
fun AslHorizontalDivider(
modifier: Modifier = Modifier,
thickness: Dp = 1.dp,
color: Color = AslTheme.colors.borderSubtle,
) {
HorizontalDivider(modifier = modifier, thickness = thickness, color = color)
}

@Composable
fun AslVerticalDivider(
modifier: Modifier = Modifier,
thickness: Dp = 1.dp,
color: Color = AslTheme.colors.borderSubtle,
) {
VerticalDivider(modifier = modifier, thickness = thickness, color = color)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.ahmadkharfan.androidstudiolite.designsystem.component.content

import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit

/**
* The app's text primitive.
*
* Features render text through this rather than Material's `Text` so the design system stays the
* only module that depends on Material — swapping the underlying implementation, or changing how
* unspecified colours resolve, then happens in one place instead of across every feature.
*/
@Composable
fun AslText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
style: TextStyle = LocalTextStyle.current,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
textAlign: TextAlign? = null,
softWrap: Boolean = true,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
fontFamily: FontFamily? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
letterSpacing: TextUnit = TextUnit.Unspecified,
) {
Text(
text = text,
modifier = modifier,
color = color,
style = style,
maxLines = maxLines,
overflow = overflow,
textAlign = textAlign,
softWrap = softWrap,
fontWeight = fontWeight,
fontSize = fontSize,
fontFamily = fontFamily,
lineHeight = lineHeight,
letterSpacing = letterSpacing,
)
}

/** [AslText] for pre-styled text, e.g. syntax-highlighted spans. */
@Composable
fun AslText(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
style: TextStyle = LocalTextStyle.current,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
) {
Text(
text = text,
modifier = modifier,
color = color,
style = style,
maxLines = maxLines,
overflow = overflow,
softWrap = softWrap,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.ahmadkharfan.androidstudiolite.designsystem.component.ide

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.ahmadkharfan.androidstudiolite.designsystem.theme.AslTheme

/**
* Screen scaffold for the app.
*
* Exists so features do not import Material directly: the container colour comes from the design
* system's own tokens rather than Material's colour scheme, which is what keeps a screen looking
* right when the theme changes.
*/
@Composable
fun AslScaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
containerColor: Color = AslTheme.colors.surface,
contentColor: Color = AslTheme.colors.textPrimary,
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = topBar,
bottomBar = bottomBar,
floatingActionButton = floatingActionButton,
snackbarHost = snackbarHost,
containerColor = containerColor,
contentColor = contentColor,
contentWindowInsets = contentWindowInsets,
content = content,
)
}

/** Host for [AslSnackbarState]; pass to [AslScaffold]'s `snackbarHost`. */
@Composable
fun AslSnackbarHost(hostState: AslSnackbarState, modifier: Modifier = Modifier) {
SnackbarHost(hostState = hostState.delegate, modifier = modifier)
}

/**
* Snackbar state a feature can hold without touching Material.
*
* A typealias would not do: it still resolves to Material's `SnackbarHostState`, which puts Material
* back on every caller's classpath. Wrapping keeps the dependency inside the design system.
*/
@Stable
class AslSnackbarState {
internal val delegate: SnackbarHostState = SnackbarHostState()

suspend fun showSnackbar(message: String, actionLabel: String? = null): Boolean =
delegate.showSnackbar(message = message, actionLabel = actionLabel) == SnackbarResult.ActionPerformed
}

/** Remembers an [AslSnackbarState] across recompositions. */
@Composable
fun rememberAslSnackbarState(): AslSnackbarState = remember { AslSnackbarState() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.ahmadkharfan.androidstudiolite.designsystem.theme

import androidx.compose.ui.text.TextStyle

/**
* The type scale, exposed as plain [TextStyle] values.
*
* `AslTypography` is a Material `Typography`, so reading a style off it forces every caller onto
* Material's classpath. These properties hand back `androidx.compose.ui.text.TextStyle` instead,
* which is a Compose UI type — features get the same styles without depending on Material.
*/
object AslTextStyles {
val displayLarge: TextStyle get() = AslTypography.displayLarge
val displayMedium: TextStyle get() = AslTypography.displayMedium
val displaySmall: TextStyle get() = AslTypography.displaySmall
val headlineLarge: TextStyle get() = AslTypography.headlineLarge
val headlineMedium: TextStyle get() = AslTypography.headlineMedium
val headlineSmall: TextStyle get() = AslTypography.headlineSmall
val titleLarge: TextStyle get() = AslTypography.titleLarge
val titleMedium: TextStyle get() = AslTypography.titleMedium
val titleSmall: TextStyle get() = AslTypography.titleSmall
val bodyLarge: TextStyle get() = AslTypography.bodyLarge
val bodyMedium: TextStyle get() = AslTypography.bodyMedium
val bodySmall: TextStyle get() = AslTypography.bodySmall
val labelLarge: TextStyle get() = AslTypography.labelLarge
val labelMedium: TextStyle get() = AslTypography.labelMedium
val labelSmall: TextStyle get() = AslTypography.labelSmall
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
o/bundleLibRuntimeToDirDebug
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading