diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 38de076..bb22643 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,22 +21,19 @@ android { } dependencies { - // The Metro graph and every module behind it come from :shared-compose. This module is just the - // Android entry point — an Activity, a theme, and a manifest. - implementation(project(":shared-compose")) - - implementation(libs.circuit.foundation) - // Aligns every androidx.compose.* artifact on 1.12.0. Without it `foundation` and `animation`, // which nothing declares, drift to whatever material3 and Compose Multiplatform ask for. implementation(platform(libs.androidx.compose.bom)) - + // The Metro graph and every module behind it come from :shared-compose. This module is just the + // Android entry point — an Activity, a theme, and a manifest. + implementation(project(":shared-compose")) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) implementation(libs.androidx.core.ktx) - implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material3) + implementation(libs.circuit.foundation) implementation(libs.kotlinx.coroutines.android) implementation(libs.material) diff --git a/apple/build.gradle.kts b/apple/build.gradle.kts index 25e6269..fbefe98 100644 --- a/apple/build.gradle.kts +++ b/apple/build.gradle.kts @@ -15,7 +15,7 @@ import io.github.solcott.countries.build.Versions // here, not in the Xcode project, so Swift never constructs a Kotlin object graph by hand. plugins { id("formatting") - id("org.jetbrains.kotlin.multiplatform") + alias(libs.plugins.kotlin.multiplatform) // Molecule recomposes `presenter.present()`, which is a @Composable call, so this module needs // the Compose compiler even though it renders nothing. alias(libs.plugins.kotlin.compose) @@ -27,6 +27,8 @@ plugins { // equality that lets `StateFlow` conflate a frame — see `EventSink` in AppleUiState.kt for that. alias(libs.plugins.redacted) id("compose.stability") + id("detekt") + alias(libs.plugins.dependency.sorter) } // Matches the `import CountriesKit` in the Swift sources. Changing it means changing both. @@ -94,14 +96,14 @@ kotlin { // makes commonMain the web source set in `:web`. A src/appleMain would hold everything and // distinguish nothing. commonMain.dependencies { - // `api` because Swift export emits everything reachable from this module's public API, and - // :model's data classes and :presenter's LoadStatus are reachable through the facade. - api(libs.dataresult) api(project(":model")) - api(libs.uistate) api(project(":presenter")) api(libs.circuit.runtime) api(libs.circuit.runtime.screen) + // `api` because Swift export emits everything reachable from this module's public API, and + // :model's data classes and :presenter's LoadStatus are reachable through the facade. + api(libs.dataresult) + api(libs.uistate) // CoreGraph, and the repositories it vends. Not exported — Swift never sees the graph, only // what CountriesKit hands back. @@ -111,9 +113,9 @@ kotlin { // references Compose `Modifier`; nothing in this module names it, so it stays out of the // linked binary. `implementation`, so it never reaches the Swift export. implementation(libs.circuitx.subcircuit) - implementation(libs.molecule.runtime) implementation(libs.compose.runtime) implementation(libs.kotlinx.coroutines.core) + implementation(libs.molecule.runtime) } // Wired up by hand rather than by `kmp-library`, which this module deliberately does not diff --git a/apple/src/commonMain/kotlin/io/github/solcott/countries/apple/PresenterHolders.kt b/apple/src/commonMain/kotlin/io/github/solcott/countries/apple/PresenterHolders.kt index eaceb9e..4a0ee13 100644 --- a/apple/src/commonMain/kotlin/io/github/solcott/countries/apple/PresenterHolders.kt +++ b/apple/src/commonMain/kotlin/io/github/solcott/countries/apple/PresenterHolders.kt @@ -6,12 +6,12 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.launchMolecule import com.slack.circuit.runtime.Navigator import io.github.solcott.countries.model.Continent -import io.github.solcott.countries.presenter.CountryDetailPresenter import io.github.solcott.countries.presenter.CountryDetailScreen -import io.github.solcott.countries.presenter.CountryListPresenter import io.github.solcott.countries.presenter.CountryListScreen import io.github.solcott.countries.presenter.SearchAndFilterPresenter import io.github.solcott.countries.presenter.SearchAndFilterScreen +import io.github.solcott.countries.presenter.countryDetailPresenter +import io.github.solcott.countries.presenter.countryListPresenter import io.github.solcott.countries.repository.ContinentRepository import io.github.solcott.countries.repository.CountryRepository import kotlinx.coroutines.CoroutineScope @@ -97,7 +97,7 @@ internal constructor( * before anything is awaited — no optional, and no empty first render. */ val state: StateFlow = moleculeState { - val listState = CountryListPresenter(navigator, countryRepository) + val listState = countryListPresenter(navigator, countryRepository) val headerPresenter = remember { SearchAndFilterPresenter(continentRepository) } val headerState = headerPresenter.present { outerEvent -> when (outerEvent) { @@ -143,7 +143,7 @@ internal constructor( ) : PresenterHolder(scope) { val state: StateFlow = moleculeState { - CountryDetailPresenter(screen, navigator, countryRepository).toUiState() + countryDetailPresenter(screen, navigator, countryRepository).toUiState() } fun back() { diff --git a/build-logic/src/main/kotlin/app.gradle.kts b/build-logic/src/main/kotlin/app.gradle.kts index 5d45ac0..79dc5f4 100644 --- a/build-logic/src/main/kotlin/app.gradle.kts +++ b/build-logic/src/main/kotlin/app.gradle.kts @@ -1,7 +1,11 @@ import io.github.solcott.countries.build.Versions import org.jetbrains.kotlin.gradle.dsl.JvmTarget -plugins { id("com.android.application") } +plugins { + id("com.android.application") + id("detekt") + id("com.squareup.sort-dependencies") +} android { compileSdk = Versions.compileSdk diff --git a/build-logic/src/main/kotlin/detekt.gradle.kts b/build-logic/src/main/kotlin/detekt.gradle.kts new file mode 100644 index 0000000..7c09e8d --- /dev/null +++ b/build-logic/src/main/kotlin/detekt.gradle.kts @@ -0,0 +1,38 @@ +import dev.detekt.gradle.Detekt +import dev.detekt.gradle.extensions.DetektExtension +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension + +plugins { id("dev.detekt") } + +configure { + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + buildUponDefaultConfig = true + allRules = false +} + +tasks.withType { + reports { html.required = true } + + exclude { + // excludes build directories + val buildDirectory = project.layout.buildDirectory.get().asFile + it.file.absolutePath.startsWith(buildDirectory.absolutePath) || + it.file.path.contains("/build/") || + it.file.name == "SharedBuildConfig.kt" + } +} + +dependencies { + val composeRulesDep = versionCatalog.findLibrary("detekt.compose.rules").get() + add("detektPlugins", composeRulesDep) +} + +tasks.register("detektAll") { + group = "verification" + val detektTasks = tasks.withType().matching { it.name != "detektDevJvm" } + dependsOn(detektTasks) +} + +val Project.versionCatalog + get() = extensions.getByType(VersionCatalogsExtension::class.java).named("libs") diff --git a/build-logic/src/main/kotlin/kmp-library.gradle.kts b/build-logic/src/main/kotlin/kmp-library.gradle.kts index 820bbd5..08bc6f5 100644 --- a/build-logic/src/main/kotlin/kmp-library.gradle.kts +++ b/build-logic/src/main/kotlin/kmp-library.gradle.kts @@ -9,6 +9,8 @@ plugins { id("formatting") id("org.jetbrains.kotlin.multiplatform") id("com.android.kotlin.multiplatform.library") + id("com.squareup.sort-dependencies") + id("detekt") } // Captured here rather than inline: inside `kotlin { android { } }`, `name` resolves to the diff --git a/build.gradle.kts b/build.gradle.kts index a7223d6..af8b161 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,12 +5,17 @@ import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.metro) apply false alias(libs.plugins.apollo) apply false alias(libs.plugins.ktfmt) apply false alias(libs.plugins.kmp.parcelize) apply false + alias(libs.plugins.compose.hot.reload) apply false alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.dependency.sorter) apply false } // Pins the Gradle daemon's JVM. `./gradlew updateDaemonJvm` writes the criteria to diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..448402c --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,962 @@ +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: ['my_rule_set', '.*>.*>[my_property]'] + excludes: [] + +processors: + active: true + exclude: + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectCyclomaticComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'IssuesReport' + - 'FileBasedIssuesReport' + # - 'LiteIssuesReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateIsRegex: false + licenseTemplate: '' + DeprecatedBlockTag: + active: false + DocumentationOverPrivateFunction: + active: false + DocumentationOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + exhaustive: true + UndocumentedPublicClass: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + ignoreDefaultCompanionObject: false + UndocumentedPublicFunction: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false + ignoreEnumEntries: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + allowedComplexity: 15 + ComplexCondition: + active: true + allowedConditions: 3 + ComplexInterface: + active: false + allowedDefinitions: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + allowedComplexity: 14 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + ignoreLocalFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + allowedLines: 600 + LongMethod: + active: true + allowedLines: 60 + LongParameterList: + active: true + allowedFunctionParameters: 5 + allowedConstructorParameters: 6 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + ignoreAnnotated: + - 'Composable' + MethodOverloading: + active: false + allowedOverloads: 6 + NamedArguments: + active: false + allowedArguments: 3 + ignoreMethods: [] + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + allowedDepth: 4 + NestedScopeFunctions: + active: false + allowedDepth: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + allowedDuplications: 2 + ignoreAnnotation: true + allowedWithLengthLessThan: 5 + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + allowedFunctionsPerFile: 11 + allowedFunctionsPerClass: 11 + allowedFunctionsPerInterface: 11 + allowedFunctionsPerObject: 11 + allowedFunctionsPerEnum: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreInternal: false + ignoreOverridden: false + ignoreAnnotatedFunctions: ['Preview', 'PreviewLightDark','PreviewComponentWidth', 'PreviewAppScreen'] + +coroutines: + active: true + CoroutineLaunchedInTestWithoutRunTest: + active: false + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunInFinallySection: + active: false + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + aliases: ['SuspendFunctionOnCoroutineScope'] + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKotlinFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ErrorUsageWithThrowable: + active: false + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + aliases: ['ClassName'] + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + aliases: ['EnumEntryName'] + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionNameMaxLength: + active: false + aliases: ['FunctionMaxNameLength'] + maximumFunctionNameLength: 30 + FunctionNameMinLength: + active: false + aliases: ['FunctionMinNameLength'] + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + aliases: ['FunctionName'] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + ignoreAnnotated: ['Composable'] + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + aliases: ['PackageDirectoryMismatch'] + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + multiplatformTargets: + - 'ios' + - 'android' + - 'js' + - 'jvm' + - 'native' + - 'iosArm64' + - 'iosX64' + - 'macosX64' + - 'mingwX64' + - 'linuxX64' + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + allowSingleTypedGenerics: false + ObjectPropertyNaming: + active: true + aliases: ['ObjectPropertyName'] + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + aliases: ['PackageName'] + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + aliases: ['PropertyName'] + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + allowedOperations: 2 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryInitOnArray: + active: false + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + UnnecessaryTypeCasting: + active: false + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + ignorePlatformTypes: true + CastToNullableType: + active: false + CharArrayToStringCall: + active: false + Deprecation: + active: false + aliases: ['DEPRECATION'] + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + aliases: ['DoubleMutability'] + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.Function*' + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + ignoreAnnotated: ['Test'] + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + MissingSuperCall: + active: false + mustInvokeSuperAnnotations: + - 'androidx.annotation.CallSuper' + - 'javax.annotation.OverridingMethodsMustInvokeSuper' + MissingUseCall: + active: false + ignoreClass: + - 'java.io.ByteArrayInputStream' + - 'java.io.ByteArrayOutputStream' + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnamedParameterUse: + active: false + allowAdjacentDifferentTypeParams: true + allowSingleParamUse: true + ignoreArgumentsMatchingNames: true + ignoreFunctionCall: [] + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + aliases: ['UNCHECKED_CAST'] + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AbstractClassCanBeConcreteClass: + active: true + AbstractClassCanBeInterface: + active: true + ignoreSealedClasses: false + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeExpression: + active: false + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaMultipleParameters: + active: true + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + forbiddenImports: [] + allowedImports: [] + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + - reason: 'using `BigDecimal(Double)` can result in unexpected floating point precision behavior. Use `BigDecimal.valueOf(Double)` or `String.toBigDecimalOrNull()` instead.' + value: 'java.math.BigDecimal.(kotlin.Double)' + - reason: 'using `BigDecimal(String)` can result in a `NumberFormatException`. Use `String.toBigDecimalOrNull()`' + value: 'java.math.BigDecimal.(kotlin.String)' + - reason: 'It is marked as obsolete. Use `kotlin.time.measureTime` instead.' + value: 'kotlin.system.measureTimeMillis' + ForbiddenNamedParam: + active: false + methods: [] + ForbiddenOptIn: + active: false + markerClasses: [] + ForbiddenSuppress: + active: false + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: true + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConstant: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + ProtectedMemberInFinalClass: + active: true + RangeUntilInsteadOfRangeTo: + active: false + RedundantConstructorKeyword: + active: false + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifier: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingAfterPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAny: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryFullyQualifiedName: + active: false + ignoredFullyQualifiedNames: [] + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UnnecessaryReversed: + active: false + UnusedImport: + active: false + additionalOperatorSet: [] + UnusedParameter: + active: true + aliases: ['UNUSED_PARAMETER', 'unused'] + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + aliases: ['unused'] + UnusedPrivateFunction: + active: true + aliases: ['unused'] + allowedNames: '' + ignoreAnnotated: ['Preview', 'PreviewLightDark','PreviewComponentWidth', 'PreviewAppScreen'] + UnusedPrivateProperty: + active: true + aliases: ['unused'] + allowedNames: 'ignored|expected|serialVersionUID' + UnusedVariable: + active: true + aliases: ['UNUSED_VARIABLE', 'unused'] + allowedNames: 'ignored|_' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + aliases: ['CanBeVal'] + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' + +Compose: + ComposableAnnotationNaming: + active: true + ComposableNaming: + active: true + ComposableNestingDepth: + active: false + ComposableParamOrder: + active: true + CompositionLocalAllowlist: + active: true + allowedCompositionLocals: + - LocalWindowSizeClass + - LocalAppBarTitle + - LocalFlagFontFamily + - LocalAppSkin + CompositionLocalNaming: + active: true + ConditionHoist: + active: true + ContentEmitterReturningValues: + active: true + ContentSlotReused: + active: true + ContentTrailingLambda: + active: true + DefaultsVisibility: + active: true + InvalidReadOnlyComposable: + active: true + LambdaParameterEventTrailing: + active: true + LambdaParameterInRestartableEffect: + active: true + Material2: + active: false + MissingNonRestartableComposable: + active: true + # -- Ignore direct and custom @Preview composables (default: true) + # ignoresPreviews: true + MissingReadOnlyComposable: + active: true + ModifierClickableOrder: + active: true + ModifierComposed: + active: true + ModifierMissing: + active: true + ModifierNaming: + active: true + ModifierNotUsedAtRoot: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + MutableParams: + active: true + MutableStateAutoboxing: + active: true + MutableStateParam: + active: true + ParameterNaming: + active: true + PreviewAnnotationNaming: + active: true + PreviewNaming: + active: false + PreviewPublic: + active: true + RememberContentMissing: + active: true + RememberMissing: + active: true + StaleRememberUpdatedStateInRemember: + active: true + StateParam: + active: true + UnnecessaryComposable: + active: true + UnnecessaryLaunchedEffect: + active: true + # -- Calls whose dispatch or extension receiver has one of these fully qualified types keep LaunchedEffect. + # allowedCallReceiverTypes: + # - androidx.compose.ui.focus.FocusRequester + # -- Calls whose fully qualified name is listed here keep LaunchedEffect. + # allowedCallNames: + # - androidx.compose.ui.focus.FocusRequester.requestFocus + UnstableCollections: + active: false + VarsWithoutStateBacking: + active: true + ViewModelForwarding: + active: true + ViewModelInjection: + active: true diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 437d53b..a7a9be9 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -10,7 +10,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat // this shape. `:web` is multiplatform only because it has to serve js and wasmJs from one module. plugins { id("formatting") - id("org.jetbrains.kotlin.jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.compose) // Brings the `compose.desktop` extension — the packaging tasks and the OS-classified runtime. alias(libs.plugins.compose.multiplatform) @@ -27,6 +27,8 @@ plugins { // "already on the classpath with an unknown version". Same rule as the Kotlin-family plugins. id("org.jetbrains.compose.hot-reload") id("compose.stability") + alias(libs.plugins.dependency.sorter) + id("detekt") } // `Versions` reaches a module build script, not just the convention plugins: it ships in the same @@ -42,19 +44,17 @@ sourceSets.main { } dependencies { + implementation(project(":presenter")) implementation(project(":shared-compose")) implementation(project(":ui")) - implementation(project(":presenter")) - - implementation(libs.circuit.foundation) - implementation(libs.compose.runtime) - implementation(libs.compose.ui) - // The one place this project reaches for a `compose.*` accessor instead of a catalog coordinate. // It has to: skiko's runtime artifact is classified by OS *and* architecture // (skiko-awt-runtime-macos-arm64, …) and only this accessor picks the right one. The consequence // is that anything built here — including the uber jar — runs on the build host's OS only. implementation(compose.desktop.currentOs) + implementation(libs.circuit.foundation) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) testImplementation(kotlin("test")) } diff --git a/desktop/src/main/kotlin/io/github/solcott/countries/desktop/Main.kt b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/Main.kt index e3404da..d5ef7b8 100644 --- a/desktop/src/main/kotlin/io/github/solcott/countries/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/github/solcott/countries/desktop/Main.kt @@ -1,9 +1,11 @@ package io.github.solcott.countries.desktop import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.key.isAltPressed import androidx.compose.ui.input.key.isCtrlPressed @@ -83,7 +85,7 @@ private fun startApplication() = application { // CountriesApp mounts, so LocalCircuitSaver is not in scope yet. val backStack = rememberSaveableBackStack(root = CountryListScreen, circuitSaver = graph.circuitSaver) - val listCollapsed = rememberSaveable { mutableStateOf(false) } + var listCollapsed by rememberSaveable { mutableStateOf(false) } val windowState = rememberWindowState( initialBoundsProvider = @@ -119,7 +121,7 @@ private fun startApplication() = application { isMetaPressed = event.isMetaPressed, isCtrlPressed = event.isCtrlPressed, ) -> { - listCollapsed.value = !listCollapsed.value + listCollapsed = !listCollapsed true } else -> false @@ -134,9 +136,10 @@ private fun startApplication() = application { CountriesApp( circuit = graph.circuit, subCircuit = graph.subCircuit, + listCollapsed = listCollapsed, + onListCollapsedChange = { listCollapsed = it }, skin = DesktopSkin, backStack = backStack, - listCollapsed = listCollapsed, ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b703ebe..ed09a38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -214,7 +214,10 @@ kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", vers ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } kmp-parcelize = { id = "io.github.solcott.kmp.parcelize", version.ref = "kmp-parcelize" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin"} # Needed even though dependencies are declared by coordinate: this plugin configures skiko's # npm/webpack packaging, which compose.foundation pulls in on js and wasmJs. compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +compose-hot-reload = { id = "org.jetbrains.compose.hot-reload", version = "1.3.0-alpha02" } redacted = { id = "dev.zacsweers.redacted", version = "1.18.0" } diff --git a/network/build.gradle.kts b/network/build.gradle.kts index 5310d51..10c766e 100644 --- a/network/build.gradle.kts +++ b/network/build.gradle.kts @@ -37,8 +37,8 @@ kotlin { // constructs that driver itself rather than calling createDefaultWebWorkerDriver(), so it can // point it at a worker that persists — see NetworkProviders.web.kt and npm/ below. webMain.dependencies { - implementation(libs.sqldelight.web.worker.driver) implementation(libs.kotlinx.browser) + implementation(libs.sqldelight.web.worker.driver) } // npm() is only available to JS-family source sets, so these are declared per target rather @@ -49,12 +49,18 @@ kotlin { // shape that works from a *library* module — it is also exactly how the reference worker // ships. jsMain.dependencies { - implementation(npm("countries-sqljs-idb-worker", file("npm/countries-sqljs-idb-worker"))) - implementation(npm("sql.js", "1.8.0")) + val worker = npm("countries-sqljs-idb-worker", file("npm/countries-sqljs-idb-worker")) + val sqlJs = npm("sql.js", "1.8.0") + + implementation(sqlJs) + implementation(worker) } wasmJsMain.dependencies { - implementation(npm("countries-sqljs-idb-worker", file("npm/countries-sqljs-idb-worker"))) - implementation(npm("sql.js", "1.8.0")) + val worker = npm("countries-sqljs-idb-worker", file("npm/countries-sqljs-idb-worker")) + val sqlJs = npm("sql.js", "1.8.0") + + implementation(sqlJs) + implementation(worker) } } } diff --git a/network/src/commonMain/kotlin/io/github/solcott/countries/network/CountriesApi.kt b/network/src/commonMain/kotlin/io/github/solcott/countries/network/CountriesApi.kt index 0303b61..ab3612a 100644 --- a/network/src/commonMain/kotlin/io/github/solcott/countries/network/CountriesApi.kt +++ b/network/src/commonMain/kotlin/io/github/solcott/countries/network/CountriesApi.kt @@ -81,13 +81,6 @@ private fun String?.asStartsWithOperator(): Optional return Optional.present(StringQueryOperatorInput(regex = Optional.present(pattern))) } -/** Exact-match operator, or [Optional.absent] for a null/blank value. */ -private fun String?.asEqualsOperator(): Optional { - val value = this?.trim().orEmpty() - return if (value.isEmpty()) absent() - else Optional.present(StringQueryOperatorInput(eq = Optional.present(value))) -} - private fun List.asInOperator(): Optional { return if (isEmpty()) absent() else Optional.present(StringQueryOperatorInput(`in` = Optional.present(this))) diff --git a/presenter/build.gradle.kts b/presenter/build.gradle.kts index 9d57917..f2360d3 100644 --- a/presenter/build.gradle.kts +++ b/presenter/build.gradle.kts @@ -40,11 +40,7 @@ kotlin { } commonMain.dependencies { - api(libs.dataresult) api(project(":model")) - api(libs.uistate) - // produceContentState, which brings circuit-retained with it. - implementation(libs.uistateCircuit) api(libs.circuit.runtime) api(libs.circuit.runtime.presenter) // `api`: @CircuitSerializable is on the Screens, so it is part of their public API. @@ -52,17 +48,21 @@ kotlin { // `api`: SubScreen, SubCircuitUiState and SubCircuitOuterEvent are all supertypes in // SearchAndFilterScreen's public API. api(libs.circuitx.subcircuit) + api(libs.dataresult) + api(libs.uistate) + implementation(project(":repository")) + implementation(libs.androidx.compose.runtime.retain) implementation(libs.circuit.codegen.annotations) - + implementation(libs.compose.foundation) // androidx.compose.runtime is already multiplatform, so `compose.runtime` here is a thin // alias onto it. foundation is not, hence the Compose Multiplatform build — it is what // provides TextFieldState. implementation(libs.compose.runtime) implementation(libs.compose.runtime.annotations) implementation(libs.compose.runtime.saveable) - implementation(libs.compose.foundation) - implementation(libs.androidx.compose.runtime.retain) + // produceContentState, which brings circuit-retained with it. + implementation(libs.uistateCircuit) } commonTest.dependencies { diff --git a/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt b/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt index 8f7af92..3faf899 100644 --- a/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt +++ b/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryDetailPresenter.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged @CircuitInject(CountryDetailScreen::class, AppScope::class) @Inject @Composable -fun CountryDetailPresenter( +fun countryDetailPresenter( screen: CountryDetailScreen, navigator: Navigator, repository: CountryRepository, diff --git a/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt b/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt index f12a3cf..3f06d74 100644 --- a/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt +++ b/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/CountryListPresenter.kt @@ -39,7 +39,7 @@ private data class SearchFilter( @CircuitInject(CountryListScreen::class, AppScope::class) @Inject @Composable -fun CountryListPresenter( +fun countryListPresenter( navigator: Navigator, repository: CountryRepository, ): CountryListScreen.State { diff --git a/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/SearchAndFilterPresenter.kt b/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/SearchAndFilterPresenter.kt index 796fa74..dcc5d12 100644 --- a/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/SearchAndFilterPresenter.kt +++ b/presenter/src/commonMain/kotlin/io/github/solcott/countries/presenter/SearchAndFilterPresenter.kt @@ -3,7 +3,7 @@ package io.github.solcott.countries.presenter import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -60,7 +60,7 @@ class SearchAndFilterPresenter(private val continentRepository: ContinentReposit // first query the host used to make for itself — and once per change after that. Debouncing // stays with the host, so typing is still smoothed while a continent toggle still lands // immediately. - LaunchedEffect(name, continents) { + SideEffect(name, continents) { outerEventSink(SearchAndFilterScreen.OuterEvent.FilterChanged(name, continents)) } diff --git a/presenter/src/commonTest/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt b/presenter/src/commonTest/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt index 4ee722c..a9d547a 100644 --- a/presenter/src/commonTest/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt +++ b/presenter/src/commonTest/kotlin/io/github/solcott/countries/presenter/CountryListPresenterTest.kt @@ -84,7 +84,7 @@ class CountryListPresenterTest { val repository = FakeCountryRepository(countriesAsFlow = { _, _ -> flowOf(data(listOf(canada))) }) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { assertTrue(awaitItem().countriesState.isLoading) val loaded = awaitCountriesSettled() @@ -112,7 +112,7 @@ class CountryListPresenterTest { } ) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { val initial = awaitCountriesSettledState() assertEquals(countries, initial.countriesState.data) @@ -149,7 +149,7 @@ class CountryListPresenterTest { ) val scheduler = testScheduler - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { val initial = awaitCountriesSettledState() assertEquals(1, queries) @@ -172,7 +172,7 @@ class CountryListPresenterTest { val repository = FakeCountryRepository(countriesAsFlow = { _, _ -> flowOf(data(listOf(canada))) }) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { val loaded = awaitCountriesSettledState() loaded.eventSink(CountryListScreen.Event.CountryClicked("CA")) @@ -193,7 +193,7 @@ class CountryListPresenterTest { val repository = FakeCountryRepository(countriesAsFlow = { _, _ -> flowOf(data(listOf(canada, egypt))) }) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { val loaded = awaitCountriesSettledState() assertEquals("CA", loaded.selectedCountryCode) @@ -214,7 +214,7 @@ class CountryListPresenterTest { val repository = FakeCountryRepository(countriesAsFlow = { _, _ -> flowOf(data(listOf(canada))) }) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { val loaded = awaitCountriesSettledState() loaded.eventSink(CountryListScreen.Event.CountryClicked("CA")) @@ -232,7 +232,7 @@ class CountryListPresenterTest { val repository = FakeCountryRepository(countriesAsFlow = { _, _ -> flowOf(data(listOf(canada))) }) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { assertEquals(null, awaitCountriesSettledState().selectedCountryCode) cancelAndIgnoreRemainingEvents() } @@ -246,7 +246,7 @@ class CountryListPresenterTest { countriesAsFlow = { _, _ -> flowOf(Outcome.Error(DataError.Network, Origin.Network)) } ) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { assertTrue(awaitItem().countriesState.isLoading) val errorState = awaitCountriesSettled() @@ -276,7 +276,7 @@ class CountryListPresenterTest { } ) - presenterTestOf({ CountryListPresenter(navigator, repository) }) { + presenterTestOf({ countryListPresenter(navigator, repository) }) { val failed = awaitCountriesSettledState() assertTrue(failed.countriesState.status is LoadStatus.Failed) diff --git a/repository/build.gradle.kts b/repository/build.gradle.kts index 88ae357..67a7f7d 100644 --- a/repository/build.gradle.kts +++ b/repository/build.gradle.kts @@ -6,8 +6,9 @@ plugins { kotlin { sourceSets { commonMain.dependencies { - api(libs.dataresult) api(project(":model")) + api(libs.dataresult) + implementation(project(":network")) // The ApolloResponse -> Outcome mapping that used to be the top half of Mappers.kt. // `implementation`: nothing from it appears in this module's public signatures, which now @@ -21,9 +22,9 @@ kotlin { // CacheInfo, which is what `isFromCache` reads -- the tests build responses with it rather // than standing up a real normalized cache. implementation(libs.apollo.normalized.cache) - implementation(libs.kotlinx.coroutines.test) // TestLogWriter, so the mappers' logging is asserted rather than assumed. implementation(libs.kermit.test) + implementation(libs.kotlinx.coroutines.test) } } } diff --git a/repository/src/commonMain/kotlin/io/github/solcott/countries/repository/RepositoryProviders.kt b/repository/src/commonMain/kotlin/io/github/solcott/countries/repository/RepositoryProviders.kt deleted file mode 100644 index 604600f..0000000 --- a/repository/src/commonMain/kotlin/io/github/solcott/countries/repository/RepositoryProviders.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.solcott.countries.repository - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesTo - -@ContributesTo(AppScope::class) interface RepositoryProviders {} diff --git a/shared-compose/build.gradle.kts b/shared-compose/build.gradle.kts index c533d2a..93b124e 100644 --- a/shared-compose/build.gradle.kts +++ b/shared-compose/build.gradle.kts @@ -42,18 +42,17 @@ kotlin { sourceSets { commonMain.dependencies { - api(project(":shared")) - api(project(":presenter")) - // On the compile classpath so Metro aggregates :ui's CircuitProviders and Ui.Factory - // multibindings into ComposeGraph. See the comment on ComposeGraph. - api(project(":ui")) - api(libs.circuit.foundation) - // Every module contributing to AppScope has to be on the compile classpath of the module // that declares the graph, because Metro resolves contributions there. It also has to be // `api` rather than `implementation`: contributed interfaces become *supertypes* of the // generated graph, so consumers of ComposeGraph need to see them too. api(project(":network")) + api(project(":presenter")) + api(project(":shared")) + // On the compile classpath so Metro aggregates :ui's CircuitProviders and Ui.Factory + // multibindings into ComposeGraph. See the comment on ComposeGraph. + api(project(":ui")) + api(libs.circuit.foundation) } } } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 9777fc9..4c5f3f6 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -6,14 +6,14 @@ plugins { kotlin { sourceSets { commonMain.dependencies { - api(libs.dataresult) api(project(":model")) - api(project(":repository")) // On the compile classpath so Metro aggregates NetworkProviders into CoreGraph: // contributions are resolved where @DependencyGraph is compiled, not where the graph is // used. `api` because contributed interfaces become supertypes of the generated graph, so // consumers of CoreGraph must see them as well. api(project(":network")) + api(project(":repository")) + api(libs.dataresult) // `api`: Logger appears in provideLogger's signature and is injected by other modules. api(libs.kermit) } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 312efb0..860af6c 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -42,35 +42,35 @@ kotlin { } commonMain.dependencies { - implementation(libs.dataresult) - implementation(project(":model")) - implementation(libs.uistate) - // Screens, state, and events live in :presenter. UI depends on presenter, never the reverse. - implementation(project(":presenter")) // `api`: Circuit appears in CircuitProviders.provideCircuit's signature. api(libs.circuit.foundation) // `api` for the same reason: SubCircuit is in provideSubCircuit's signature, and // CountriesApp takes one. api(libs.circuitx.subcircuit) + + implementation(project(":model")) + // Screens, state, and events live in :presenter. UI depends on presenter, never the reverse. + implementation(project(":presenter")) + implementation(libs.circuit.codegen.annotations) + // presenterOf, for the fake presenters behind previewCircuit in PreviewSupport.kt. + implementation(libs.circuit.runtime.presenter) implementation(libs.circuit.runtime.ui) // SerializableCircuitSaver, for CircuitProviders. Reaches here transitively as an `api` of // :presenter — which annotates its Screens with @CircuitSerializable — but the saver is // constructed in this module, so declare it. implementation(libs.circuit.serialization) - // presenterOf, for the fake presenters behind previewCircuit in PreviewSupport.kt. - implementation(libs.circuit.runtime.presenter) - implementation(libs.circuit.codegen.annotations) - - implementation(libs.compose.runtime) + // Generates the Res class from src/commonMain/composeResources — the multiplatform + // replacement for the Android res/ directory this module used to have. + implementation(libs.compose.components.resources) implementation(libs.compose.foundation) - implementation(libs.compose.ui) implementation(libs.compose.material3) implementation(libs.compose.material3.adaptive) implementation(libs.compose.material3.adaptive.layout) - // Generates the Res class from src/commonMain/composeResources — the multiplatform - // replacement for the Android res/ directory this module used to have. - implementation(libs.compose.components.resources) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) + implementation(libs.dataresult) + implementation(libs.uistate) } } } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountriesApp.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountriesApp.kt index bc160d4..be327a0 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountriesApp.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountriesApp.kt @@ -14,10 +14,12 @@ import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirectiveWithTwoPanesOnMediumWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -55,12 +57,11 @@ import org.jetbrains.compose.resources.stringResource * It owns the app's only [Scaffold] and its only app bar. Both screens are pane content — see * [ListDetailNavDecoration], which puts them side by side on a window wide enough for two. * - * [backStack] can be hoisted because the browser app binds it to `window.history` — see `:web` — - * and [listCollapsed] for the same reason: `:desktop` toggles it from a keyboard shortcut, outside - * composition. Leave [backStack] null and one is built here, rooted at [CountryListScreen]. It is - * null-defaulted rather than given a real default because the default has to be constructed - * *inside* [CircuitCompositionLocals], which is what puts the `CircuitSaver` in scope; a hoisting - * caller has to name that saver itself, and `ComposeGraph` exposes it for exactly that. + * [backStack] can be hoisted because the browser app binds it to `window.history` — see `:web`. + * Leave [backStack] null and one is built here, rooted at [CountryListScreen]. It is null-defaulted + * rather than given a real default because the default has to be constructed *inside* + * [CircuitCompositionLocals], which is what puts the `CircuitSaver` in scope; a hoisting caller has + * to name that saver itself, and `ComposeGraph` exposes it for exactly that. * * [onRootPop] has no default in Circuit's common `rememberCircuitNavigator`; only the Android-only * overload supplies one. It stays explicit here because what "pop past the root" means is genuinely @@ -73,16 +74,47 @@ import org.jetbrains.compose.resources.stringResource * [subCircuit] is required rather than defaulted because `LocalSubCircuit` defaults to null and * `SubCircuitContent` is behind a `requireNotNull` — a platform that forgot it would build cleanly * and then throw the first time the country list drew its header. + * + * This overload keeps whether the list pane is collapsed itself. A platform that needs to drive it + * from outside composition uses the one that takes `listCollapsed` instead. + */ +@Composable +fun CountriesApp( + circuit: Circuit, + subCircuit: SubCircuit, + modifier: Modifier = Modifier, + skin: AppSkin = MaterialSkin, + backStack: SaveableBackStack? = null, + onRootPop: (PopResult?) -> Unit = {}, +) { + var listCollapsed by rememberSaveable { mutableStateOf(false) } + CountriesApp( + circuit = circuit, + subCircuit = subCircuit, + listCollapsed = listCollapsed, + onListCollapsedChange = { listCollapsed = it }, + modifier = modifier, + skin = skin, + backStack = backStack, + onRootPop = onRootPop, + ) +} + +/** + * [CountriesApp] with the list-pane collapse hoisted, for `:desktop`, which toggles it from a + * keyboard shortcut outside composition. [listCollapsed] is the current value; + * [onListCollapsedChange] is called with the value the in-app toggle asks for. */ @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun CountriesApp( circuit: Circuit, subCircuit: SubCircuit, + listCollapsed: Boolean, + onListCollapsedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, skin: AppSkin = MaterialSkin, backStack: SaveableBackStack? = null, - listCollapsed: MutableState = rememberSaveable { mutableStateOf(false) }, onRootPop: (PopResult?) -> Unit = {}, ) { AppTheme(skin) { @@ -101,6 +133,7 @@ fun CountriesApp( navigator = navigator, backStack = resolvedBackStack, listCollapsed = listCollapsed, + onListCollapsedChange = onListCollapsedChange, // Two panes from 600dp rather than Material's own 840dp, which is what the // ...OnMediumWidth variant buys. That matches the SwiftUI app, where NavigationSplitView // shows both columns on iPad mini portrait (744pt) and iPad Air portrait (834pt) — 840dp @@ -120,14 +153,14 @@ fun CountriesApp( * [CountriesApp] minus the graph wiring, so previews can force a [directive] rather than hope the * preview renderer reports the device width the preview asked for. */ -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable internal fun CountriesAppScaffold( navigator: Navigator, backStack: SaveableBackStack, directive: PaneScaffoldDirective, + listCollapsed: Boolean, + onListCollapsedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, - listCollapsed: MutableState = remember { mutableStateOf(false) }, ) { val twoPane = directive.maxHorizontalPartitions > 1 val hasDetail = backStack.topRecord?.screen is CountryDetailScreen @@ -159,9 +192,8 @@ internal fun CountriesAppScaffold( // Nothing to go back to beside two live panes: closing the detail there leaves the // placeholder, not a previous screen. onBack = if (!twoPane && backStack.size > 1) ({ navigator.pop() }) else null, - listCollapsed = listCollapsed.value, - onToggleList = - if (canCollapse) ({ listCollapsed.value = !listCollapsed.value }) else null, + listCollapsed = listCollapsed, + onToggleList = if (canCollapse) ({ onListCollapsedChange(!listCollapsed) }) else null, ) }, ) { padding -> @@ -170,8 +202,8 @@ internal fun CountriesAppScaffold( navigator = navigator, backStack = backStack, decoration = - remember(directive, listCollapsed.value) { - ListDetailNavDecoration(directive, listCollapsed.value) + remember(directive, listCollapsed) { + ListDetailNavDecoration(directive, listCollapsed) }, modifier = Modifier.fillMaxSize(), ) @@ -215,22 +247,23 @@ private fun AppChrome( @Composable private fun ContentFrame(modifier: Modifier = Modifier, content: @Composable () -> Unit) { val skin = LocalAppSkin.current + val movableContent = remember { movableContentOf(content) } if (!skin.contentMaxWidth.isSpecified && !skin.contentPanel) { - Box(modifier.fillMaxSize()) { content() } - return - } - Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - val column = Modifier.widthIn(max = skin.contentMaxWidth).fillMaxSize() - if (skin.contentPanel) { - Surface( - modifier = column.padding(horizontal = WebPageGutter).padding(bottom = WebPageGutter), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - content = content, - ) - } else { - Box(column) { content() } + Box(modifier.fillMaxSize()) { movableContent() } + } else { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + val column = Modifier.widthIn(max = skin.contentMaxWidth).fillMaxSize() + if (skin.contentPanel) { + Surface( + modifier = column.padding(horizontal = WebPageGutter).padding(bottom = WebPageGutter), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + content = movableContent, + ) + } else { + Box(column) { movableContent() } + } } } } @@ -254,13 +287,15 @@ private fun AppPreview( // rememberDefaultCircuitSaver() — correct here, since a preview never saves anything. CircuitCompositionLocals(previewCircuit()) { val backStack = rememberSaveableBackStack(screens) + var collapsed by remember { mutableStateOf(listCollapsed) } CompositionLocalProvider(LocalSubCircuit provides previewSubCircuit()) { CountriesAppScaffold( navigator = rememberCircuitNavigator(backStack) {}, backStack = backStack, directive = directive, + listCollapsed = collapsed, + onListCollapsedChange = { collapsed = it }, modifier = Modifier.fillMaxSize(), - listCollapsed = remember { mutableStateOf(listCollapsed) }, ) } } @@ -269,9 +304,9 @@ private fun AppPreview( /** * The real thing at every size we ship to, picking its own layout — the sweep that shows the split - * actually happening, since [AppScreenPreviews] spans phone, foldable, tablet and desktop. + * actually happening, since [PreviewAppScreen] spans phone, foldable, tablet and desktop. */ -@AppScreenPreviews +@PreviewAppScreen @Composable private fun CountriesAppPreview() { CountriesApp(circuit = previewCircuit(), subCircuit = previewSubCircuit()) @@ -326,8 +361,8 @@ private fun CountriesAppTwoPaneCollapsedPreview() { } /** - * Narrow with a country open: the detail fills the window and the bar grows a back button. Also the - * shape a `#/country/CH` deep link produces on a phone. + * Narrow with a country open: the detail fills the window and the bar grows a back button. Also, + * the shape a `#/country/CH` deep link produces on a phone. */ @Preview(name = "Stacked - detail", device = "spec:width=411dp,height=891dp") @Composable diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt index 1b3385c..24fe517 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryDetailUi.kt @@ -165,7 +165,7 @@ private fun DetailPreview(content: ContentState) { * two thirds of the width, so a full-window preview overstates how much room it gets — see the * two-pane previews on `CountriesApp` for the shape it is actually laid out in. */ -@AppScreenPreviews +@PreviewAppScreen @Composable private fun CountryDetailUiPreview() { DetailPreview(loadedState(previewCountryDetail)) @@ -190,20 +190,20 @@ private fun CountryDetailUiNotFoundPreview() { DetailPreview(loadedState(null)) } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountryDetailContentPreview() { PreviewSurface { CountryDetailContent(previewCountryDetail) } } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun DetailRowPreview() { PreviewSurface { DetailRow("Capital: Bern") } } /** The desktop skin's detail pane: compact type and the five fields as an inspector. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountryDetailContentDesktopSkinPreview() { PreviewSurface(skin = DesktopSkin) { CountryDetailContent(previewCountryDetail) } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt index 69d47dc..a7ed4fe 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/CountryListUi.kt @@ -256,7 +256,7 @@ private fun ListPreview( * 340dp beside the detail, so a full-window preview overstates its width — see the two-pane * previews on `CountriesApp` for the shape it is actually laid out in. */ -@AppScreenPreviews +@PreviewAppScreen @Composable private fun CountryListUiPreview() { ListPreview(loadedState(previewCountries)) @@ -311,7 +311,7 @@ private fun CountryListUiWithoutContinentsPreview() { ListPreview(loadedState(previewCountries), continentsState = loadingState(emptyList())) } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountriesListPreview() { PreviewSurface { @@ -328,27 +328,27 @@ private fun CountriesListPreview() { } } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun RefreshingIndicatorPreview() { PreviewSurface { RefreshingIndicator() } } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountryRowPreview() { PreviewSurface { CountryRow(country = previewCountries.first(), onClick = {}) } } /** The two-pane layout's selected row, marking which country the detail pane is showing. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountryRowSelectedPreview() { PreviewSurface { CountryRow(country = previewCountries.first(), onClick = {}, selected = true) } } /** The desktop sidebar row: dense, dimmed second line, and an inset rounded selection. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountryRowDesktopSkinPreview() { PreviewSurface(skin = DesktopSkin) { @@ -362,7 +362,7 @@ private fun CountryRowDesktopSkinPreview() { /** * The same list without rules between the rows, which is the other half of reading as a sidebar. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountriesListDesktopSkinPreview() { PreviewSurface(skin = DesktopSkin) { diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/DesktopToolbar.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/DesktopToolbar.kt index e46a4d9..4879856 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/DesktopToolbar.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/DesktopToolbar.kt @@ -123,7 +123,7 @@ private fun ToolbarNavigation( } /** Two live panes with a country open: the toggle, offering to hide the list. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun DesktopToolbarPreview() { PreviewSurface(skin = DesktopSkin) { DesktopToolbar(onToggleList = {}) } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ErrorContent.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ErrorContent.kt index 566a9c7..7ca3d0c 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ErrorContent.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ErrorContent.kt @@ -36,7 +36,7 @@ private fun ErrorContentPreview() { } /** The longest message in the set — the one that decides whether the column wraps sensibly. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun ErrorContentWidthsPreview() { PreviewSurface { ErrorContent(message = DataError.Serialization.toUserMessage(), onRetry = {}) } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ListDetailNavDecoration.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ListDetailNavDecoration.kt index 5780393..82f5e84 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ListDetailNavDecoration.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/ListDetailNavDecoration.kt @@ -12,7 +12,10 @@ import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationIt import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue import androidx.compose.material3.adaptive.layout.calculateThreePaneScaffoldValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.Stable +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.slack.circuit.foundation.NavDecoration import com.slack.circuit.foundation.ProvideRecordLifecycle @@ -87,6 +90,7 @@ internal class ListDetailNavDecoration( modifier: Modifier, content: @Composable (T) -> Unit, ) { + val movableContent = remember { movableContentOf(content) } // CountryListScreen is the root and is never navigated *to*, so the stack is [list] or // [list, detail] and these two reads describe it completely. val listArg = args.root @@ -125,12 +129,14 @@ internal class ListDetailNavDecoration( }, modifier = modifier, listPane = { - AnimatedPane(Modifier.preferredWidth(listPaneWidth)) { OnScreen { content(listArg) } } + AnimatedPane(Modifier.preferredWidth(listPaneWidth)) { + OnScreen { movableContent(listArg) } + } }, detailPane = { AnimatedPane { if (detailArg == null) NoCountrySelected(Modifier.fillMaxSize()) - else OnScreen { content(detailArg) } + else OnScreen { movableContent(detailArg) } } }, ) @@ -151,6 +157,7 @@ internal class ListDetailNavDecoration( * screen and should be running. */ @Composable +@NonRestartableComposable private fun OnScreen(content: @Composable () -> Unit) { ProvideRecordLifecycle(isActive = true, content = content) } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/NoCountrySelected.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/NoCountrySelected.kt index 87ca37e..3a4f67d 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/NoCountrySelected.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/NoCountrySelected.kt @@ -60,7 +60,7 @@ fun NoCountrySelected(modifier: Modifier = Modifier) { } /** 360dp is the width at which the description wraps to three lines — the case worth looking at. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun NoCountrySelectedPreview() { PreviewSurface { NoCountrySelected() } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/PreviewSupport.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/PreviewSupport.kt index 83bad34..58e4cde 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/PreviewSupport.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/PreviewSupport.kt @@ -46,7 +46,7 @@ import io.github.solcott.uistate.LoadStatus @Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION) @PreviewScreenSizes @Preview(name = "Browser - compact", device = "spec:width=800dp,height=600dp,dpi=160") -annotation class AppScreenPreviews +annotation class PreviewAppScreen /** * Widths for a composable that is a strip inside a screen rather than a screen: a small phone, a @@ -57,7 +57,7 @@ annotation class AppScreenPreviews @Preview(name = "Compact 360dp", widthDp = 360, showBackground = true) @Preview(name = "Medium 700dp", widthDp = 700, showBackground = true) @Preview(name = "Expanded 1280dp", widthDp = 1280, showBackground = true) -annotation class ComponentWidthPreviews +annotation class PreviewComponentWidth /** * Wrapper every preview renders through, so previews use the real color scheme and typography diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchAndFilterUi.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchAndFilterUi.kt index ce05a57..586c7a1 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchAndFilterUi.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchAndFilterUi.kt @@ -3,7 +3,6 @@ package io.github.solcott.countries.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -45,28 +44,6 @@ import io.github.solcott.countries.ui.theme.LocalAppSkin import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -/** - * Registers [SearchAndFilterUi] as the sub-circuit's UI. - * - * Metro also accepts `@SubCircuitInject` straight on a top-level composable, which would make this - * class unnecessary — but the `SubUiFactory` it generates for a *function* holds a reference to - * that function, and lowering that reference crashes the Kotlin/JS and Kotlin/Wasm back-ends - * (`UpgradeCallableReferences`, IndexOutOfBounds). Only the two web targets are affected; JVM, - * Android and native compile it happily, so a build that skipped them would look fine. - * - * A class target is generated without the reference, and it costs nothing: [SearchAndFilterUi] - * stays a plain composable with the default `modifier` the project's conventions ask for, and stays - * previewable, which an override of `Content` would not be. - */ -@SubCircuitInject(SearchAndFilterScreen::class, AppScope::class) -@Inject -class SearchAndFilterSubUi : SubUi { - @Composable - override fun Content(state: SearchAndFilterScreen.State, modifier: Modifier) { - SearchAndFilterUi(state, modifier) - } -} - /** * The search box and continent filter at the top of the list pane. * @@ -77,16 +54,8 @@ class SearchAndFilterSubUi : SubUi { * rather than routing keystrokes through the event sink, which is the whole point of a * `TextFieldState`. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchAndFilterUi(state: SearchAndFilterScreen.State, modifier: Modifier = Modifier) { - // A filter is a toggle, and a tick on a toggle is native behaviour on Android as well as in a - // mobile browser — which is what makes this one call correct everywhere and not an `AppSkin` - // token. Compose Multiplatform 1.12 is what made it real on js and wasmJs; on desktop and macOS - // it is a documented no-op. Country-row selection is deliberately *not* haptic: Android list rows - // do not vibrate on tap, and serving the browser there would cost the Android app its native - // feel. - val haptics = LocalHapticFeedback.current Column( modifier .fillMaxWidth() @@ -99,69 +68,92 @@ fun SearchAndFilterUi(state: SearchAndFilterScreen.State, modifier: Modifier = M verticalAlignment = Alignment.CenterVertically, ) { SearchField(state.nameStartsWithText, modifier = Modifier.weight(1f)) - val continents = state.continentsState.data + ContinentsDropdownMenuBox(state) + } + ActiveFilterChips(state, Modifier.padding(top = 8.dp)) + } +} - if (continents.isNotEmpty()) { +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ContinentsDropdownMenuBox(state: SearchAndFilterScreen.State) { + val continents = state.continentsState.data + val haptics = LocalHapticFeedback.current - ExposedDropdownMenuBox( - state.continentDropdownExpanded, - onExpandedChange = { - state.eventSink(SearchAndFilterScreen.Event.DropdownExpandedChanged(it)) - }, - ) { - IconButton( + if (continents.isNotEmpty()) { + ExposedDropdownMenuBox( + state.continentDropdownExpanded, + onExpandedChange = { + state.eventSink(SearchAndFilterScreen.Event.DropdownExpandedChanged(it)) + }, + ) { + IconButton( + onClick = { + state.eventSink( + SearchAndFilterScreen.Event.DropdownExpandedChanged(!state.continentDropdownExpanded) + ) + } + ) { + val selectedCount = state.selectedContinents.size + // The badge is inside the button rather than around it: the button is the + // ExposedDropdownMenuBox's anchor, and wrapping it would put a layout between the two. + BadgedBox(badge = { if (selectedCount > 0) Badge { Text(selectedCount.toString()) } }) { + Icon( + painterResource(Res.drawable.filter_list_24px), + contentDescription = + if (selectedCount == 0) stringResource(Res.string.filter) + else stringResource(Res.string.filter_count, selectedCount), + ) + } + } + ExposedDropdownMenu( + expanded = state.continentDropdownExpanded, + onDismissRequest = { + state.eventSink(SearchAndFilterScreen.Event.DropdownExpandedChanged(false)) + }, + modifier = Modifier.width(200.dp), + ) { + continents.forEach { continent -> + val isSelected = state.selectedContinents.contains(continent) + DropdownMenuItem( + text = { Text(continent.name) }, onClick = { - state.eventSink( - SearchAndFilterScreen.Event.DropdownExpandedChanged( - !state.continentDropdownExpanded - ) - ) - } - ) { - val selectedCount = state.selectedContinents.size - // The badge is inside the button rather than around it: the button is the - // ExposedDropdownMenuBox's anchor, and wrapping it would put a layout between the two. - BadgedBox(badge = { if (selectedCount > 0) Badge { Text(selectedCount.toString()) } }) { - Icon( - painterResource(Res.drawable.filter_list_24px), - contentDescription = - if (selectedCount == 0) stringResource(Res.string.filter) - else stringResource(Res.string.filter_count, selectedCount), - ) - } - } - ExposedDropdownMenu( - expanded = state.continentDropdownExpanded, - onDismissRequest = { + haptics.toggled(nowOn = !isSelected) state.eventSink(SearchAndFilterScreen.Event.DropdownExpandedChanged(false)) + state.eventSink(SearchAndFilterScreen.Event.ContinentToggled(continent)) }, - modifier = Modifier.width(200.dp), - ) { - continents.forEach { continent -> - val isSelected = state.selectedContinents.contains(continent) - DropdownMenuItem( - text = { Text(continent.name) }, - onClick = { - haptics.toggled(nowOn = !isSelected) - state.eventSink(SearchAndFilterScreen.Event.DropdownExpandedChanged(false)) - state.eventSink(SearchAndFilterScreen.Event.ContinentToggled(continent)) - }, - trailingIcon = { - if (isSelected) { - Icon(painterResource(Res.drawable.check_small_24px), "Checked") - } - }, - ) - } - } + trailingIcon = { + if (isSelected) { + Icon(painterResource(Res.drawable.check_small_24px), "Checked") + } + }, + ) } } } + } +} - val activeFilters = activeFiltersOf(state) - if (activeFilters.isNotEmpty()) { - ActiveFilterChips(activeFilters, Modifier.padding(top = 8.dp)) - } +/** + * Registers [SearchAndFilterUi] as the sub-circuit's UI. + * + * Metro also accepts `@SubCircuitInject` straight on a top-level composable, which would make this + * class unnecessary — but the `SubUiFactory` it generates for a *function* holds a reference to + * that function, and lowering that reference crashes the Kotlin/JS and Kotlin/Wasm back-ends + * (`UpgradeCallableReferences`, IndexOutOfBounds). Only the two web targets are affected; JVM, + * Android and native compile it happily, so a build that skipped them would look fine. + * + * A class target is generated without the reference, and it costs nothing: [SearchAndFilterUi] + * stays a plain composable with the default `modifier` the project's conventions ask for, and stays + * previewable, which an override of `Content` would not be. + */ +@Suppress("unused") +@SubCircuitInject(SearchAndFilterScreen::class, AppScope::class) +@Inject +class SearchAndFilterSubUi : SubUi { + @Composable + override fun Content(state: SearchAndFilterScreen.State, modifier: Modifier) { + SearchAndFilterUi(state, modifier) } } @@ -200,38 +192,41 @@ private fun activeFiltersOf(state: SearchAndFilterScreen.State): List, modifier: Modifier = Modifier) { +private fun ActiveFilterChips(state: SearchAndFilterScreen.State, modifier: Modifier = Modifier) { val haptics = LocalHapticFeedback.current - FlowRow( - modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - filters.forEach { filter -> - InputChip( - selected = true, - // A chip only ever clears, so this is always the off direction. The haptic sits here rather - // than inside `onClear` so [ActiveFilter] stays a plain description of a filter. - onClick = { - haptics.toggled(nowOn = false) - filter.onClear() - }, - label = { Text(filter.label) }, - trailingIcon = { - Icon( - painterResource(Res.drawable.close_24px), - contentDescription = filter.removeDescription, - modifier = Modifier.size(InputChipDefaults.IconSize), - ) - }, - ) + val activeFilters = activeFiltersOf(state) + if (activeFilters.isNotEmpty()) { + FlowRow( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + activeFilters.forEach { filter -> + InputChip( + selected = true, + // A chip only ever clears, so this is always the off direction. The haptic sits here + // rather + // than inside `onClear` so [ActiveFilter] stays a plain description of a filter. + onClick = { + haptics.toggled(nowOn = false) + filter.onClear() + }, + label = { Text(filter.label) }, + trailingIcon = { + Icon( + painterResource(Res.drawable.close_24px), + contentDescription = filter.removeDescription, + modifier = Modifier.size(InputChipDefaults.IconSize), + ) + }, + ) + } } } } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchAndFilterUiPreview() { PreviewSurface { @@ -242,7 +237,7 @@ private fun SearchAndFilterUiPreview() { } /** Empty, which is what the list opens on. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchAndFilterUiEmptyPreview() { PreviewSurface { SearchAndFilterUi(searchAndFilterState()) } @@ -252,7 +247,7 @@ private fun SearchAndFilterUiEmptyPreview() { * Continents still loading — the case that removes the filter control entirely, leaving the search * field the full width. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchAndFilterUiWithoutContinentsPreview() { PreviewSurface { @@ -261,7 +256,7 @@ private fun SearchAndFilterUiWithoutContinentsPreview() { } /** The menu open with two continents ticked, which is the only way to see the check marks. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchAndFilterUiExpandedPreview() { PreviewSurface { @@ -275,7 +270,7 @@ private fun SearchAndFilterUiExpandedPreview() { } /** The desktop skin, where the search field is a compact bordered row rather than a filled one. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchAndFilterUiDesktopSkinPreview() { PreviewSurface(skin = DesktopSkin) { @@ -288,20 +283,14 @@ private fun SearchAndFilterUiDesktopSkinPreview() { * where the wrap is actually visible — inside [SearchAndFilterUi] the search field takes the width * first. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun ActiveFilterChipsPreview() { PreviewSurface { ActiveFilterChips( - previewContinents - .filter { it.code in setOf("EU", "AS", "NA", "SA") } - .map { continent -> - ActiveFilter( - label = continent.name, - removeDescription = "Remove ${continent.name} filter", - onClear = {}, - ) - } + searchAndFilterState( + selectedContinents = previewContinents.filter { it.code in setOf("EU", "AS", "NA", "SA") } + ) ) } } @@ -310,7 +299,7 @@ private fun ActiveFilterChipsPreview() { * Several filters at once, which is the case the chip row wraps for. The two longest continent * names are picked deliberately — they are what overflows one line in the list pane's width. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchAndFilterUiManyFiltersPreview() { PreviewSurface { @@ -323,7 +312,7 @@ private fun SearchAndFilterUiManyFiltersPreview() { } /** The chip row under the desktop skin, whose denser metrics it has to survive. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchAndFilterUiFiltersDesktopSkinPreview() { PreviewSurface(skin = DesktopSkin) { diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchField.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchField.kt index 788118f..0e43a1c 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchField.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/SearchField.kt @@ -93,7 +93,7 @@ private fun CompactSearchField(state: TextFieldState, modifier: Modifier = Modif } } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun SearchFieldPreview() { PreviewSurface { SearchField(TextFieldState("Fra")) } @@ -106,7 +106,7 @@ private fun SearchFieldEmptyPreview() { PreviewSurface { SearchField(TextFieldState()) } } -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CompactSearchFieldPreview() { PreviewSurface(skin = DesktopSkin) { SearchField(TextFieldState("Fra")) } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/TopAppBar.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/TopAppBar.kt index 7700bc9..45172a8 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/TopAppBar.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/TopAppBar.kt @@ -113,7 +113,7 @@ fun CountriesTopAppBar( } /** The list on a narrow window, and two panes with nothing picked: the globe, and nothing to do. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountriesTopAppBarPreview() { PreviewSurface { CountriesTopAppBar() } @@ -141,7 +141,7 @@ private fun CountriesTopAppBarToggleCollapsedPreview() { } /** The name that does not fit, which is what the ellipsis is for. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun CountriesTopAppBarLongTitlePreview() { PreviewSurface { CountriesTopAppBar(title = previewCountries.last().name, onBack = {}) } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/WebHeader.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/WebHeader.kt index 8826034..eb00529 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/WebHeader.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/WebHeader.kt @@ -131,7 +131,7 @@ private fun HeaderRow( } /** The wordmark, which is what the header shows almost all of the time. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun WebHeaderPreview() { PreviewSurface(skin = WebSkin) { WebHeader() } @@ -144,7 +144,7 @@ private fun WebHeaderLightDarkPreview() { } /** Two live panes with a country open: the toggle, on the right where a view control belongs. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun WebHeaderWithTogglePreview() { PreviewSurface(skin = WebSkin) { WebHeader(onToggleList = {}) } @@ -158,7 +158,7 @@ private fun WebHeaderWithBackPreview() { } /** The name that does not fit, which is what the ellipsis is for. */ -@ComponentWidthPreviews +@PreviewComponentWidth @Composable private fun WebHeaderLongTitlePreview() { PreviewSurface(skin = WebSkin) { WebHeader(title = previewCountries.last().name, onBack = {}) } diff --git a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/theme/Theme.kt b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/theme/Theme.kt index 21e4375..9cddfdc 100644 --- a/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/theme/Theme.kt +++ b/ui/src/commonMain/kotlin/io/github/solcott/countries/ui/theme/Theme.kt @@ -88,162 +88,6 @@ internal val darkScheme = surfaceContainerHighest = surfaceContainerHighestDark, ) -private val mediumContrastLightColorScheme = - lightColorScheme( - primary = primaryLightMediumContrast, - onPrimary = onPrimaryLightMediumContrast, - primaryContainer = primaryContainerLightMediumContrast, - onPrimaryContainer = onPrimaryContainerLightMediumContrast, - secondary = secondaryLightMediumContrast, - onSecondary = onSecondaryLightMediumContrast, - secondaryContainer = secondaryContainerLightMediumContrast, - onSecondaryContainer = onSecondaryContainerLightMediumContrast, - tertiary = tertiaryLightMediumContrast, - onTertiary = onTertiaryLightMediumContrast, - tertiaryContainer = tertiaryContainerLightMediumContrast, - onTertiaryContainer = onTertiaryContainerLightMediumContrast, - error = errorLightMediumContrast, - onError = onErrorLightMediumContrast, - errorContainer = errorContainerLightMediumContrast, - onErrorContainer = onErrorContainerLightMediumContrast, - background = backgroundLightMediumContrast, - onBackground = onBackgroundLightMediumContrast, - surface = surfaceLightMediumContrast, - onSurface = onSurfaceLightMediumContrast, - surfaceVariant = surfaceVariantLightMediumContrast, - onSurfaceVariant = onSurfaceVariantLightMediumContrast, - outline = outlineLightMediumContrast, - outlineVariant = outlineVariantLightMediumContrast, - scrim = scrimLightMediumContrast, - inverseSurface = inverseSurfaceLightMediumContrast, - inverseOnSurface = inverseOnSurfaceLightMediumContrast, - inversePrimary = inversePrimaryLightMediumContrast, - surfaceDim = surfaceDimLightMediumContrast, - surfaceBright = surfaceBrightLightMediumContrast, - surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, - surfaceContainerLow = surfaceContainerLowLightMediumContrast, - surfaceContainer = surfaceContainerLightMediumContrast, - surfaceContainerHigh = surfaceContainerHighLightMediumContrast, - surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, - ) - -private val highContrastLightColorScheme = - lightColorScheme( - primary = primaryLightHighContrast, - onPrimary = onPrimaryLightHighContrast, - primaryContainer = primaryContainerLightHighContrast, - onPrimaryContainer = onPrimaryContainerLightHighContrast, - secondary = secondaryLightHighContrast, - onSecondary = onSecondaryLightHighContrast, - secondaryContainer = secondaryContainerLightHighContrast, - onSecondaryContainer = onSecondaryContainerLightHighContrast, - tertiary = tertiaryLightHighContrast, - onTertiary = onTertiaryLightHighContrast, - tertiaryContainer = tertiaryContainerLightHighContrast, - onTertiaryContainer = onTertiaryContainerLightHighContrast, - error = errorLightHighContrast, - onError = onErrorLightHighContrast, - errorContainer = errorContainerLightHighContrast, - onErrorContainer = onErrorContainerLightHighContrast, - background = backgroundLightHighContrast, - onBackground = onBackgroundLightHighContrast, - surface = surfaceLightHighContrast, - onSurface = onSurfaceLightHighContrast, - surfaceVariant = surfaceVariantLightHighContrast, - onSurfaceVariant = onSurfaceVariantLightHighContrast, - outline = outlineLightHighContrast, - outlineVariant = outlineVariantLightHighContrast, - scrim = scrimLightHighContrast, - inverseSurface = inverseSurfaceLightHighContrast, - inverseOnSurface = inverseOnSurfaceLightHighContrast, - inversePrimary = inversePrimaryLightHighContrast, - surfaceDim = surfaceDimLightHighContrast, - surfaceBright = surfaceBrightLightHighContrast, - surfaceContainerLowest = surfaceContainerLowestLightHighContrast, - surfaceContainerLow = surfaceContainerLowLightHighContrast, - surfaceContainer = surfaceContainerLightHighContrast, - surfaceContainerHigh = surfaceContainerHighLightHighContrast, - surfaceContainerHighest = surfaceContainerHighestLightHighContrast, - ) - -private val mediumContrastDarkColorScheme = - darkColorScheme( - primary = primaryDarkMediumContrast, - onPrimary = onPrimaryDarkMediumContrast, - primaryContainer = primaryContainerDarkMediumContrast, - onPrimaryContainer = onPrimaryContainerDarkMediumContrast, - secondary = secondaryDarkMediumContrast, - onSecondary = onSecondaryDarkMediumContrast, - secondaryContainer = secondaryContainerDarkMediumContrast, - onSecondaryContainer = onSecondaryContainerDarkMediumContrast, - tertiary = tertiaryDarkMediumContrast, - onTertiary = onTertiaryDarkMediumContrast, - tertiaryContainer = tertiaryContainerDarkMediumContrast, - onTertiaryContainer = onTertiaryContainerDarkMediumContrast, - error = errorDarkMediumContrast, - onError = onErrorDarkMediumContrast, - errorContainer = errorContainerDarkMediumContrast, - onErrorContainer = onErrorContainerDarkMediumContrast, - background = backgroundDarkMediumContrast, - onBackground = onBackgroundDarkMediumContrast, - surface = surfaceDarkMediumContrast, - onSurface = onSurfaceDarkMediumContrast, - surfaceVariant = surfaceVariantDarkMediumContrast, - onSurfaceVariant = onSurfaceVariantDarkMediumContrast, - outline = outlineDarkMediumContrast, - outlineVariant = outlineVariantDarkMediumContrast, - scrim = scrimDarkMediumContrast, - inverseSurface = inverseSurfaceDarkMediumContrast, - inverseOnSurface = inverseOnSurfaceDarkMediumContrast, - inversePrimary = inversePrimaryDarkMediumContrast, - surfaceDim = surfaceDimDarkMediumContrast, - surfaceBright = surfaceBrightDarkMediumContrast, - surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, - surfaceContainerLow = surfaceContainerLowDarkMediumContrast, - surfaceContainer = surfaceContainerDarkMediumContrast, - surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, - surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, - ) - -private val highContrastDarkColorScheme = - darkColorScheme( - primary = primaryDarkHighContrast, - onPrimary = onPrimaryDarkHighContrast, - primaryContainer = primaryContainerDarkHighContrast, - onPrimaryContainer = onPrimaryContainerDarkHighContrast, - secondary = secondaryDarkHighContrast, - onSecondary = onSecondaryDarkHighContrast, - secondaryContainer = secondaryContainerDarkHighContrast, - onSecondaryContainer = onSecondaryContainerDarkHighContrast, - tertiary = tertiaryDarkHighContrast, - onTertiary = onTertiaryDarkHighContrast, - tertiaryContainer = tertiaryContainerDarkHighContrast, - onTertiaryContainer = onTertiaryContainerDarkHighContrast, - error = errorDarkHighContrast, - onError = onErrorDarkHighContrast, - errorContainer = errorContainerDarkHighContrast, - onErrorContainer = onErrorContainerDarkHighContrast, - background = backgroundDarkHighContrast, - onBackground = onBackgroundDarkHighContrast, - surface = surfaceDarkHighContrast, - onSurface = onSurfaceDarkHighContrast, - surfaceVariant = surfaceVariantDarkHighContrast, - onSurfaceVariant = onSurfaceVariantDarkHighContrast, - outline = outlineDarkHighContrast, - outlineVariant = outlineVariantDarkHighContrast, - scrim = scrimDarkHighContrast, - inverseSurface = inverseSurfaceDarkHighContrast, - inverseOnSurface = inverseOnSurfaceDarkHighContrast, - inversePrimary = inversePrimaryDarkHighContrast, - surfaceDim = surfaceDimDarkHighContrast, - surfaceBright = surfaceBrightDarkHighContrast, - surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, - surfaceContainerLow = surfaceContainerLowDarkHighContrast, - surfaceContainer = surfaceContainerDarkHighContrast, - surfaceContainerHigh = surfaceContainerHighDarkHighContrast, - surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, - ) - @Immutable data class ColorFamily( val color: Color, @@ -252,14 +96,6 @@ data class ColorFamily( val onColorContainer: Color, ) -val unspecified_scheme = - ColorFamily( - Color.Unspecified, - Color.Unspecified, - Color.Unspecified, - Color.Unspecified, - ) - /** * The theme, for whichever platform is asking. * diff --git a/web/build.gradle.kts b/web/build.gradle.kts index 9e9e03d..4e9b7cd 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -9,7 +9,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl // which is what turns a klib into a webpack bundle. plugins { id("formatting") - id("org.jetbrains.kotlin.multiplatform") + alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.compose) // Required even though every dependency is declared by catalog coordinate: this is what // configures skiko's npm/webpack packaging, which compose.ui pulls in on js and wasmJs. @@ -18,6 +18,8 @@ plugins { // contribution to it, is aggregated on :shared-compose's compile classpath — not here. alias(libs.plugins.metro) id("compose.stability") + alias(libs.plugins.dependency.sorter) + id("detekt") } kotlin { @@ -39,9 +41,9 @@ kotlin { // Multiplatform writes its own ComposeViewport in a shared webMain. No expect/actual needed // for the history binding. commonMain.dependencies { + implementation(project(":presenter")) implementation(project(":shared-compose")) implementation(project(":ui")) - implementation(project(":presenter")) implementation(libs.circuit.foundation) implementation(libs.compose.runtime) implementation(libs.compose.ui)