diff --git a/CLAUDE.md b/CLAUDE.md index 80f1cbf821..55130dbcf9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -480,6 +480,47 @@ Configuration in `.editorconfig`: - **Entry functions:** `{feature}Entries` - **Use cases:** `{Action}{Domain}UseCase` (e.g., `GetHomeDataUseCase`) +### Imports + +**Import the type, never the namespace.** An import may shorten a qualified reference only when the +short name still says what it is to someone reading that line cold, without scrolling to the import +list. Sealed subclasses, enum entries and other types pass that test. A member reached through a +receiver that carries the meaning does not. + +Always allowed (this is the house style, ~1200 such imports exist): + +```kotlin +import com.hedvig.android.feature.home.home.ui.HomeUiState.Success // `is Success ->` reads fine +import com.hedvig.android.design.system.hedvig.TooltipDefaults.BeakDirection.TopEnd +import kotlin.time.Duration.Companion.seconds // enables the `5.seconds` idiom +``` + +Never allowed, because the receiver is the meaning: + +```kotlin +import hedvig.resources.Res.string // ❌ `stringResource(string.FOO)` → use `Res.string.FOO` +import hedvig.resources.Res.drawable // ❌ `painterResource(drawable.x)` → use `Res.drawable.x` +import kotlin.time.Clock.System // ❌ `System.now()` → use `Clock.System.now()` +import ...hedvig.TooltipDefaults.defaultStyle // ❌ `defaultStyle` alone names nothing +``` + +`Res` and `Clock` are the two that come up most: 193 files import `hedvig.resources.Res` plainly and +that is the standard. `System.now()` additionally reads as `java.lang.System` to anyone skimming. + +**Separately: never make an import-only change to a line you are not otherwise editing.** Converting +existing `HomeEvent.RefreshData` call sites to a bare `RefreshData` (or the reverse) is a whole-file +rewrite disguised as a diff. It buries the real change under churn and makes review and `git blame` +worse for no behavioural gain. + +**Why:** both halves of this rule protect the reader. The first protects whoever reads the line +later, the second protects whoever reviews the PR now. PR #3100 was one screen refactor carrying 29 +gratuitous new imports and ~60 rewritten call sites, and the formatting noise overshadowed the +actual work. + +**How to apply:** if you are touching a line for a real reason, use the correct form. If you are not +touching it, leave its qualification exactly as it is. Import cleanups that are genuinely wanted go +in their own commit. + ### Comments Code comments and KDoc must describe the **current** code and stand on their own. A comment fails to earn its place in two ways: it tells the wrong kind of story, or it repeats what is already there. Before writing one, apply the test: *would this make sense to someone reading the file cold, with no knowledge of the PR, the conversation, or what was decided against?* If not, it does not belong in the source. Do not reference: diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ClickableList.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ClickableList.kt index 3a7b62da7a..7b20638907 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ClickableList.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/ClickableList.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp -import com.hedvig.android.design.system.hedvig.ClickableListDefaults.iconSize import com.hedvig.android.design.system.hedvig.icon.ChevronRight import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.tokens.ColorSchemeKeyTokens @@ -96,7 +95,7 @@ private fun ClickableListItem( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { - Icon(HedvigIcons.ChevronRight, "", Modifier.size(iconSize)) + Icon(HedvigIcons.ChevronRight, "", Modifier.size(ClickableListDefaults.iconSize)) } }, spaceBetween = 4.dp, diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt index 080797e28b..6d226b271b 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Dialog.kt @@ -40,7 +40,6 @@ import com.hedvig.android.design.system.hedvig.DialogDefaults.ButtonSize.SMALL import com.hedvig.android.design.system.hedvig.DialogDefaults.DialogStyle import com.hedvig.android.design.system.hedvig.DialogDefaults.DialogStyle.Buttons import com.hedvig.android.design.system.hedvig.DialogDefaults.DialogStyle.NoButtons -import com.hedvig.android.design.system.hedvig.DialogDefaults.defaultButtonSize import com.hedvig.android.design.system.hedvig.EmptyStateDefaults.EmptyStateButtonStyle import com.hedvig.android.design.system.hedvig.EmptyStateDefaults.EmptyStateIconStyle.ERROR import com.hedvig.android.design.system.hedvig.tokens.DialogTokens @@ -86,7 +85,7 @@ fun HedvigAlertDialog( modifier: Modifier = Modifier, confirmButtonLabel: String = stringResource(Res.string.GENERAL_YES), dismissButtonLabel: String = stringResource(Res.string.GENERAL_NO), - buttonSize: DialogDefaults.ButtonSize = defaultButtonSize, + buttonSize: DialogDefaults.ButtonSize = DialogDefaults.defaultButtonSize, ) { HedvigAlertDialog( title = AnnotatedString(title), @@ -109,7 +108,7 @@ fun HedvigAlertDialog( modifier: Modifier = Modifier, confirmButtonLabel: String = stringResource(Res.string.GENERAL_YES), dismissButtonLabel: String = stringResource(Res.string.GENERAL_NO), - buttonSize: DialogDefaults.ButtonSize = defaultButtonSize, + buttonSize: DialogDefaults.ButtonSize = DialogDefaults.defaultButtonSize, ) { HedvigDialog( style = Buttons( diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/HedvigBigCard.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/HedvigBigCard.kt index 02fc97e4ec..2e4a52c097 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/HedvigBigCard.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/HedvigBigCard.kt @@ -15,9 +15,6 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.hedvig.android.compose.ui.LayoutWithoutPlacement -import com.hedvig.android.design.system.hedvig.BigCardDefaults.inputTextStyle -import com.hedvig.android.design.system.hedvig.BigCardDefaults.labelTextStyle -import com.hedvig.android.design.system.hedvig.BigCardDefaults.padding import com.hedvig.android.design.system.hedvig.tokens.ColorSchemeKeyTokens import com.hedvig.android.design.system.hedvig.tokens.TypographyKeyTokens @@ -57,7 +54,7 @@ fun HedvigBigCard( inputText: String?, modifier: Modifier = Modifier, enabled: Boolean = true, - textStyle: TextStyle = inputTextStyle, + textStyle: TextStyle = BigCardDefaults.inputTextStyle, ) { Surface( shape = HedvigTheme.shapes.cornerLarge, @@ -72,14 +69,14 @@ fun HedvigBigCard( LayoutWithoutPlacement( sizeAdjustingContent = { // Always take up the space that the two texts would take - Column(Modifier.padding(padding)) { - HedvigText(text = labelText, style = labelTextStyle) + Column(Modifier.padding(BigCardDefaults.padding)) { + HedvigText(text = labelText, style = BigCardDefaults.labelTextStyle) HedvigText(text = "H", style = textStyle) } }, ) { if (inputText == null) { - Box(Modifier.padding(padding)) { + Box(Modifier.padding(BigCardDefaults.padding)) { HedvigText( text = labelText, style = textStyle, @@ -88,10 +85,10 @@ fun HedvigBigCard( ) } } else { - Column(Modifier.padding(padding)) { + Column(Modifier.padding(BigCardDefaults.padding)) { HedvigText( text = labelText, - style = labelTextStyle, + style = BigCardDefaults.labelTextStyle, color = bigCardColors.labelTextColor(enabled), ) HedvigText( diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Notification.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Notification.kt index 511ea7f4d8..a05e5a0e8c 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Notification.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Notification.kt @@ -43,10 +43,6 @@ import com.hedvig.android.design.system.hedvig.NotificationDefaults.Notification import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.Info import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.InfoInline import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.NeutralToast -import com.hedvig.android.design.system.hedvig.NotificationDefaults.defaultStyle -import com.hedvig.android.design.system.hedvig.NotificationDefaults.paddingNoIcon -import com.hedvig.android.design.system.hedvig.NotificationDefaults.paddingWithIcon -import com.hedvig.android.design.system.hedvig.NotificationDefaults.textStyle import com.hedvig.android.design.system.hedvig.icon.Campaign import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.icon.InfoFilled @@ -82,7 +78,7 @@ fun HedvigNotificationCard( priority: NotificationPriority, modifier: Modifier = Modifier, withIcon: Boolean = NotificationDefaults.withIconDefault, - style: InfoCardStyle = defaultStyle, + style: InfoCardStyle = NotificationDefaults.defaultStyle, buttonLoading: Boolean = false, minLines: Int = 1, ) { @@ -104,10 +100,10 @@ fun HedvigNotificationCard( priority: NotificationPriority, modifier: Modifier = Modifier, withIcon: Boolean = NotificationDefaults.withIconDefault, - style: InfoCardStyle = defaultStyle, + style: InfoCardStyle = NotificationDefaults.defaultStyle, buttonLoading: Boolean = false, ) { - val padding = if (withIcon) paddingWithIcon else paddingNoIcon + val padding = if (withIcon) NotificationDefaults.paddingWithIcon else NotificationDefaults.paddingNoIcon val description = when (priority) { Attention, NotificationPriority.AttentionRound, Error, Info -> stringResource(Res.string.TALKBACK_NOTIFICATION_CARD) Campaign, InfoInline, NeutralToast, FancyInfo -> "" @@ -125,7 +121,7 @@ fun HedvigNotificationCard( border = if (priority !is FancyInfo) priority.colors.borderColor else null, ) { val buttonDarkTheme = if (priority is InfoInline) isSystemInDarkTheme() else false - ProvideTextStyle(textStyle) { + ProvideTextStyle(NotificationDefaults.textStyle) { Row(Modifier.padding(padding)) { if (withIcon) { LayoutWithoutPlacement( @@ -156,7 +152,7 @@ fun HedvigNotificationCard( buttonSize = Small, modifier = Modifier.weight(1f), ) { - HedvigText(style.leftButtonText, style = textStyle) + HedvigText(style.leftButtonText, style = NotificationDefaults.textStyle) } Spacer(Modifier.width(4.dp)) HedvigButton( @@ -166,7 +162,7 @@ fun HedvigNotificationCard( buttonSize = Small, modifier = Modifier.weight(1f), ) { - HedvigText(style.rightButtonText, style = textStyle) + HedvigText(style.rightButtonText, style = NotificationDefaults.textStyle) } } } @@ -184,11 +180,11 @@ fun HedvigNotificationCard( ) { LayoutWithoutPlacement( sizeAdjustingContent = { - HedvigText(style.buttonText, style = textStyle) + HedvigText(style.buttonText, style = NotificationDefaults.textStyle) }, ) { if (!buttonLoading) { - HedvigText(style.buttonText, style = textStyle) + HedvigText(style.buttonText, style = NotificationDefaults.textStyle) } else { Box( modifier = Modifier.fillMaxSize(), diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Tooltip.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Tooltip.kt index 51f9fe76ae..cfebcca170 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Tooltip.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Tooltip.kt @@ -49,10 +49,6 @@ import com.hedvig.android.design.system.hedvig.TooltipDefaults.TooltipStyle.Camp import com.hedvig.android.design.system.hedvig.TooltipDefaults.TooltipStyle.Campaign.Brightness.BLEAK import com.hedvig.android.design.system.hedvig.TooltipDefaults.TooltipStyle.Campaign.Brightness.BRIGHT import com.hedvig.android.design.system.hedvig.TooltipDefaults.TooltipStyle.Inbox -import com.hedvig.android.design.system.hedvig.TooltipDefaults.arrowHeightDp -import com.hedvig.android.design.system.hedvig.TooltipDefaults.arrowSpaceFromEdgeWhenOffCenteredDp -import com.hedvig.android.design.system.hedvig.TooltipDefaults.arrowWidthDp -import com.hedvig.android.design.system.hedvig.TooltipDefaults.defaultStyle import com.hedvig.android.design.system.hedvig.tokens.TooltipTokens import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay @@ -63,7 +59,7 @@ fun HedvigTooltip( showTooltip: Boolean, tooltipShown: () -> Unit, modifier: Modifier = Modifier, - tooltipStyle: TooltipStyle = defaultStyle, + tooltipStyle: TooltipStyle = TooltipDefaults.defaultStyle, beakDirection: BeakDirection = BottomCenter, maxWidth: Dp = TooltipDefaults.defaultMaxWidth, ) { @@ -150,9 +146,11 @@ private fun InnerChatTooltip( private fun Shape.withBeak(beakDirection: BeakDirection): Shape { return object : Shape { override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { - val arrowSpaceFromEdgeWhenOffCentered: Float = with(density) { arrowSpaceFromEdgeWhenOffCenteredDp.toPx() } - val arrowWidth = with(density) { arrowWidthDp.toPx() } - val arrowHeight = with(density) { arrowHeightDp.toPx() } + val arrowSpaceFromEdgeWhenOffCentered: Float = with(density) { + TooltipDefaults.arrowSpaceFromEdgeWhenOffCenteredDp.toPx() + } + val arrowWidth = with(density) { TooltipDefaults.arrowWidthDp.toPx() } + val arrowHeight = with(density) { TooltipDefaults.arrowHeightDp.toPx() } val squircleSize: Size = when (beakDirection) { BottomCenter, BottomStart, BottomEnd, TopCenter, TopStart, TopEnd -> { size.copy(height = size.height - arrowHeight) diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/TopAppBar.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/TopAppBar.kt index fc624cdb91..e02e662b31 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/TopAppBar.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/TopAppBar.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.dropUnlessResumed -import com.hedvig.android.design.system.hedvig.TopAppBarDefaults.windowInsets import com.hedvig.android.design.system.hedvig.icon.ArrowLeft import com.hedvig.android.design.system.hedvig.icon.Close import com.hedvig.android.design.system.hedvig.icon.HedvigIcons @@ -183,7 +182,7 @@ fun TopAppBarLayoutForActions( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically, modifier = modifier - .windowInsetsPadding(windowInsets) + .windowInsetsPadding(TopAppBarDefaults.windowInsets) .height(TopAppBarTokens.ContainerHeight) .fillMaxWidth() .padding(contentPadding), diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextOverlay.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextOverlay.kt index 8aca058ddc..a1fa4bec38 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextOverlay.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextOverlay.kt @@ -56,7 +56,6 @@ import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.HorizontalDivider import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.design.system.hedvig.freetext.FreeTextDefaults.counterPadding import com.hedvig.android.design.system.hedvig.fromToken import com.hedvig.android.design.system.hedvig.internal.Decoration import com.hedvig.android.design.system.hedvig.tokens.ColorSchemeKeyTokens.BackgroundBlack @@ -241,7 +240,7 @@ private fun FreeTextOverlayContent( color = freeTextColors.labelColor, modifier = Modifier .fillMaxWidth() - .padding(counterPadding) + .padding(FreeTextDefaults.counterPadding) .wrapContentWidth(Alignment.End) .semantics { contentDescription = characterLimitDescription diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextTriggerDisplay.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextTriggerDisplay.kt index dd2ad8a6bb..67b179696f 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextTriggerDisplay.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/freetext/FreeTextTriggerDisplay.kt @@ -33,10 +33,6 @@ import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults. import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults.Height.Unlimited import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults.Style import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults.Style.Labeled -import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults.contentPadding -import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults.defaultHeight -import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults.defaultStyle -import com.hedvig.android.design.system.hedvig.freetext.FreeTextDisplayDefaults.supportingTextPadding import com.hedvig.android.design.system.hedvig.fromToken import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.icon.WarningFilled @@ -64,8 +60,8 @@ fun FreeTextDisplay( freeTextPlaceholder: String, modifier: Modifier = Modifier, maxLength: Int = FreeTextDisplayDefaults.maxLength, - height: Height = defaultHeight, - style: Style = defaultStyle, + height: Height = FreeTextDisplayDefaults.defaultHeight, + style: Style = FreeTextDisplayDefaults.defaultStyle, hasError: Boolean = false, supportingText: String? = null, showCount: Boolean = true, @@ -87,7 +83,7 @@ fun FreeTextDisplay( color = freeTextColors.displayContainerColor, ) { Column( - Modifier.padding(contentPadding), + Modifier.padding(FreeTextDisplayDefaults.contentPadding), ) { if (style is Labeled && freeTextValue != null) { Row(Modifier.fillMaxWidth()) { @@ -147,7 +143,7 @@ fun FreeTextDisplay( text = supportingText, color = displayColors.supportingTextColor, style = FreeTextDisplayDefaults.countLabelStyle.value, - modifier = Modifier.padding(supportingTextPadding), + modifier = Modifier.padding(FreeTextDisplayDefaults.supportingTextPadding), ) } } diff --git a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/SubmittedAndClosedColumns.kt b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/SubmittedAndClosedColumns.kt index 4f1ba9dc96..ee84d2e8e4 100644 --- a/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/SubmittedAndClosedColumns.kt +++ b/app/feature/feature-claim-details/src/main/kotlin/com/hedvig/android/feature/claim/details/ui/SubmittedAndClosedColumns.kt @@ -21,7 +21,6 @@ import hedvig.resources.claim_status_detail_closed import hedvig.resources.claim_status_detail_submitted import java.util.Locale import kotlin.time.Clock -import kotlin.time.Clock.System import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds @@ -77,7 +76,7 @@ private fun SubmittedAndClosedColumn(topText: String, bottomText: String, modifi } @Composable -private fun currentTimeAsState(updateInterval: Duration = 1.seconds, clock: Clock = System): State { +private fun currentTimeAsState(updateInterval: Duration = 1.seconds, clock: Clock = Clock.System): State { return produceState(initialValue = clock.now()) { while (isActive) { delay(updateInterval) @@ -123,8 +122,8 @@ private fun PreviewSubmittedAndClosedInformation() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { SubmittedAndClosedColumns( - submittedAt = System.now().minus(10.days), - closedAt = System.now().minus(30.seconds), + submittedAt = Clock.System.now().minus(10.days), + closedAt = Clock.System.now().minus(30.seconds), locale = Locale.ENGLISH, ) } diff --git a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt index 5f337228cc..98a9fe96f8 100644 --- a/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt +++ b/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt @@ -225,8 +225,7 @@ import hedvig.resources.RESUME_CLAIM_DELETE_BUTTON import hedvig.resources.RESUME_CLAIM_DELETE_TITLE import hedvig.resources.RESUME_CLAIM_EXPIRED_BODY import hedvig.resources.RESUME_CLAIM_EXPIRED_TITLE -import hedvig.resources.Res.drawable -import hedvig.resources.Res.string +import hedvig.resources.Res import hedvig.resources.TOAST_NEW_OFFER import hedvig.resources.blur_background import hedvig.resources.general_cancel_button @@ -237,7 +236,6 @@ import hedvig.resources.home_tab_welcome_title_without_name import hedvig.resources.ongoing_shop_session_dismiss_offer import kotlin.math.roundToInt import kotlin.time.Clock -import kotlin.time.Clock.System import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -388,8 +386,8 @@ private fun HomeScreen( // The draft is expired, so acknowledging the notice (Close button, scrim, or back) removes it. // Matches the Ready-for-dev design: single Close, closing removes the draft claim card. ErrorDialog( - title = stringResource(string.RESUME_CLAIM_EXPIRED_TITLE), - message = stringResource(string.RESUME_CLAIM_EXPIRED_BODY), + title = stringResource(Res.string.RESUME_CLAIM_EXPIRED_TITLE), + message = stringResource(Res.string.RESUME_CLAIM_EXPIRED_BODY), onDismiss = { showDraftExpiredDialog = false draftClaim?.let { deleteDraftClaim(it.id) } @@ -399,10 +397,10 @@ private fun HomeScreen( val draftIdToDelete = draftIdPendingDeleteConfirmation if (draftIdToDelete != null) { HedvigAlertDialog( - title = stringResource(string.RESUME_CLAIM_DELETE_TITLE), - text = stringResource(string.RESUME_CLAIM_DELETE_BODY), - confirmButtonLabel = stringResource(string.RESUME_CLAIM_DELETE_BUTTON), - dismissButtonLabel = stringResource(string.general_cancel_button), + title = stringResource(Res.string.RESUME_CLAIM_DELETE_TITLE), + text = stringResource(Res.string.RESUME_CLAIM_DELETE_BODY), + confirmButtonLabel = stringResource(Res.string.RESUME_CLAIM_DELETE_BUTTON), + dismissButtonLabel = stringResource(Res.string.general_cancel_button), onDismissRequest = { draftIdPendingDeleteConfirmation = null }, onConfirmClick = { draftIdPendingDeleteConfirmation = null @@ -457,7 +455,7 @@ private fun HomeScreen( }, onContinueDraftClaim = { if (draftClaim != null) { - if (draftClaim.isExpired(System.now())) { + if (draftClaim.isExpired(Clock.System.now())) { showDraftExpiredDialog = true } else { navigateToClaimChat(true) @@ -558,7 +556,7 @@ private fun HomeScreenTopBar( } if (shouldShowNewMessageTooltip) { HedvigTooltip( - message = stringResource(string.CHAT_NEW_MESSAGE), + message = stringResource(Res.string.CHAT_NEW_MESSAGE), showTooltip = shouldShowNewMessageTooltip, tooltipStyle = Inbox, beakDirection = TopEnd, @@ -587,7 +585,7 @@ private fun ColumnScope.CrossSellsTooltip(uiState: Success, setEpochDayWhenLastT var shouldSetEpochDayWhenLastToolTipShown by remember { mutableStateOf(false) } LaunchedEffect(shouldSetEpochDayWhenLastToolTipShown) { if (shouldSetEpochDayWhenLastToolTipShown) { - val today = System.now().toLocalDateTime( + val today = Clock.System.now().toLocalDateTime( TimeZone.currentSystemDefault(), ).date.toEpochDays() delay(5000.milliseconds) @@ -596,7 +594,7 @@ private fun ColumnScope.CrossSellsTooltip(uiState: Success, setEpochDayWhenLastT } if (shouldShowCrossSellsTooltip) { HedvigTooltip( - message = stringResource(string.TOAST_NEW_OFFER), + message = stringResource(Res.string.TOAST_NEW_OFFER), showTooltip = true, tooltipStyle = Campaign( subMessage = null, @@ -683,7 +681,7 @@ private fun HomeScreenSuccess( // to "hide" it (the content cards already do; so do the pinned pills). if (HedvigTheme.colorScheme.isLight) { Image( - painter = painterResource(drawable.blur_background), + painter = painterResource(Res.drawable.blur_background), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), @@ -1212,7 +1210,7 @@ private fun MemberRemindersSection( .padding(horizontalInsets), ) { HedvigText( - text = stringResource(string.HOME_TODO_SECTION_TITLE), + text = stringResource(Res.string.HOME_TODO_SECTION_TITLE), style = HedvigTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }, ) @@ -1244,7 +1242,7 @@ private fun QuotesSection( val contentPadding = PaddingValues(horizontal = 16.dp) + horizontalInsets Column(Modifier.fillMaxWidth()) { HedvigText( - text = stringResource(string.HOME_QUOTES_SECTION_TITLE), + text = stringResource(Res.string.HOME_QUOTES_SECTION_TITLE), style = HedvigTheme.typography.headlineSmall, modifier = Modifier .padding(contentPadding) @@ -1301,7 +1299,7 @@ private fun QuoteCard( HedvigText(text = session.title, style = HedvigTheme.typography.bodySmall) val secondary = session.monthlyNet?.let { stringResource( - string.OFFER_COST_AND_PREMIUM_PERIOD_ABBREVIATION, + Res.string.OFFER_COST_AND_PREMIUM_PERIOD_ABBREVIATION, it, ) } ?: session.subtitle @@ -1323,13 +1321,13 @@ private fun QuoteCard( ) { Icon( imageVector = HedvigIcons.Close, - contentDescription = stringResource(string.ongoing_shop_session_dismiss_offer), + contentDescription = stringResource(Res.string.ongoing_shop_session_dismiss_offer), ) } } Spacer(Modifier.height(12.dp)) HedvigButton( - text = stringResource(string.general_continue_button), + text = stringResource(Res.string.general_continue_button), onClick = { onResumeClick(session.resumeUrl) }, buttonStyle = Secondary, buttonSize = ButtonSize.Medium, @@ -1357,7 +1355,7 @@ private fun QuickActionTilesSection( .padding(horizontalInsets), ) { HedvigText( - text = stringResource(string.HC_QUICK_ACTIONS_TITLE), + text = stringResource(Res.string.HC_QUICK_ACTIONS_TITLE), style = HedvigTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }, ) @@ -1485,21 +1483,21 @@ private fun MainActionCarouselSection( .padding(horizontalInsets), ) { HedvigButton( - text = stringResource(string.home_tab_claim_button_text), + text = stringResource(Res.string.home_tab_claim_button_text), onClick = onMakeClaim, enabled = true, buttonStyle = RoundedPrimary, ) if (isHelpCenterEnabled) { HedvigButton( - text = stringResource(string.home_tab_get_help), + text = stringResource(Res.string.home_tab_get_help), onClick = onHelpAndSupport, enabled = true, buttonStyle = RoundedLiquidGlass, ) } HedvigButton( - text = stringResource(string.DASHBOARD_OPEN_CHAT), + text = stringResource(Res.string.DASHBOARD_OPEN_CHAT), onClick = onContactUs, enabled = true, buttonStyle = RoundedLiquidGlass, @@ -1522,7 +1520,7 @@ private fun AddonsSection( .padding(horizontalInsets), ) { HedvigText( - text = stringResource(string.INSURANCE_ADDONS_SUBHEADING), + text = stringResource(Res.string.INSURANCE_ADDONS_SUBHEADING), style = HedvigTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }, ) @@ -1532,7 +1530,7 @@ private fun AddonsSection( subtitle = addon.description, pillowImage = null, pillow = { AddonPillow(addon.flowType) }, - buttonText = stringResource(string.HOME_ADDONS_READ_MORE_BUTTON), + buttonText = stringResource(Res.string.HOME_ADDONS_READ_MORE_BUTTON), onButtonClick = { navigateToAddonPurchaseFlow(addon.eligibleInsurancesIds) }, imageLoader = imageLoader, modifier = Modifier.fillMaxWidth(), @@ -1550,13 +1548,13 @@ private fun DiscoverInsurancesSection( imageLoader: ImageLoader, ) { CrossSellsSection( - title = stringResource(string.HOME_DISCOVER_SECTION_TITLE), + title = stringResource(Res.string.HOME_DISCOVER_SECTION_TITLE), crossSells = crossSells, onCrossSellClick = onCrossSellClick, modifier = Modifier.padding(horizontal = 16.dp), onSheetDismissed = {}, imageLoader = imageLoader, - buttonText = stringResource(string.HOME_DISCOVER_SEE_PRICE_BUTTON), + buttonText = stringResource(Res.string.HOME_DISCOVER_SEE_PRICE_BUTTON), buttonSize = ButtonSize.Small, buttonShape = HedvigTheme.shapes.cornerFull, ) @@ -1573,7 +1571,7 @@ private fun WelcomeMessage(firstName: String, modifier: Modifier = Modifier) { ) if (firstName.isBlank()) { HedvigText( - text = stringResource(string.home_tab_welcome_title_without_name), + text = stringResource(Res.string.home_tab_welcome_title_without_name), style = titleStyle, modifier = modifier.fillMaxWidth(), ) @@ -1584,12 +1582,12 @@ private fun WelcomeMessage(firstName: String, modifier: Modifier = Modifier) { modifier = modifier.fillMaxWidth(), ) { HedvigText( - text = stringResource(string.HOME_GREETING_TITLE, firstName), + text = stringResource(Res.string.HOME_GREETING_TITLE, firstName), style = titleStyle, modifier = Modifier.fillMaxWidth(), ) HedvigText( - text = stringResource(string.HOME_GREETING_SUBTITLE), + text = stringResource(Res.string.HOME_GREETING_SUBTITLE), color = HedvigTheme.colorScheme.textSecondary, style = titleStyle, modifier = Modifier.fillMaxWidth(), @@ -1879,19 +1877,19 @@ private fun PreviewHomeScreenAllHomeTextTypes( private val previewQuickActions: List = listOf( MultiSelectExpandedLink( - titleRes = string.HC_QUICK_ACTIONS_EDIT_INSURANCE_TITLE, - hintTextRes = string.HC_QUICK_ACTIONS_EDIT_INSURANCE_SUBTITLE, + titleRes = Res.string.HC_QUICK_ACTIONS_EDIT_INSURANCE_TITLE, + hintTextRes = Res.string.HC_QUICK_ACTIONS_EDIT_INSURANCE_SUBTITLE, links = listOf( StandaloneQuickLink( - titleRes = string.HC_QUICK_ACTIONS_UPGRADE_COVERAGE_TITLE, - hintTextRes = string.HC_QUICK_ACTIONS_UPGRADE_COVERAGE_SUBTITLE, + titleRes = Res.string.HC_QUICK_ACTIONS_UPGRADE_COVERAGE_TITLE, + hintTextRes = Res.string.HC_QUICK_ACTIONS_UPGRADE_COVERAGE_SUBTITLE, quickLinkDestination = QuickLinkChangeTier, ), ), ), StandaloneQuickLink( - titleRes = string.HC_QUICK_ACTIONS_CHANGE_ADDRESS_TITLE, - hintTextRes = string.HC_QUICK_ACTIONS_CHANGE_ADDRESS_SUBTITLE, + titleRes = Res.string.HC_QUICK_ACTIONS_CHANGE_ADDRESS_TITLE, + hintTextRes = Res.string.HC_QUICK_ACTIONS_CHANGE_ADDRESS_SUBTITLE, quickLinkDestination = QuickLinkChangeAddress, ), ) diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt index 5d674040e5..f2c7a75f37 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt @@ -117,7 +117,7 @@ import hedvig.resources.Res import hedvig.resources.TAB_PAYMENTS_TITLE import hedvig.resources.info_card_missing_payment_body import hedvig.resources.info_card_missing_payment_missing_payments_body -import kotlin.time.Clock.System +import kotlin.time.Clock import kotlin.time.Duration.Companion.days import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -822,7 +822,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "rdg", ), upcomingPaymentInfo = NoInfo, @@ -837,7 +837,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "rdg", ), upcomingPaymentInfo = NoInfo, @@ -854,7 +854,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "iky", ), upcomingPaymentInfo = InProgress, @@ -869,12 +869,12 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(400.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "pwe", ), upcomingPaymentInfo = PaymentFailed( - System.now().toLocalDateTime(TimeZone.UTC).date, - System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, isManualChargeAllowed = ManualChargeToPrompt( UiMoney(200.0, UiCurrencyCode.SEK), ), @@ -890,7 +890,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "fkjse", ), upcomingPaymentInfo = NoInfo, @@ -905,12 +905,12 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "qrdfgeth", ), upcomingPaymentInfo = PaymentFailed( - System.now().toLocalDateTime(TimeZone.UTC).date, - System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, isManualChargeAllowed = null, ), ongoingCharges = emptyList(), @@ -926,7 +926,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "qrdfgeth2", ), upcomingPaymentInfo = NoInfo, @@ -943,17 +943,17 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "w345423t6", ), upcomingPaymentInfo = PaymentFailed( - System.now().toLocalDateTime(TimeZone.UTC).date, - System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, isManualChargeAllowed = null, ), ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayinSetup( - dueDateToConnect = System.now().plus(30.days).toLocalDateTime(TimeZone.UTC).date, + dueDateToConnect = Clock.System.now().plus(30.days).toLocalDateTime(TimeZone.UTC).date, ), showPayoutButton = false, memberType = MemberType.STANDARD_MEMBER, @@ -964,17 +964,17 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "42345", ), upcomingPaymentInfo = PaymentFailed( - System.now().toLocalDateTime(TimeZone.UTC).date, - System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().minus(30.days).toLocalDateTime(TimeZone.UTC).date, isManualChargeAllowed = null, ), ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayinSetup( - System.now().plus(30.days).toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().plus(30.days).toLocalDateTime(TimeZone.UTC).date, ), showPayoutButton = false, memberType = MemberType.STANDARD_MEMBER, @@ -1029,7 +1029,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< isRetrying = false, upcomingPayment = UpcomingPayment.Content( UiMoney(100.0, SEK), - System.now().toLocalDateTime(TimeZone.UTC).date, + Clock.System.now().toLocalDateTime(TimeZone.UTC).date, "w345423t6", ), upcomingPaymentInfo = UpcomingPaymentInfo.NoInfo, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc345f31c8..f3bd52e4e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -247,6 +247,7 @@ zXing = { module = "com.google.zxing:core", version.ref = "zXing" } zoomable = { module = "com.mxalbert.zoomable:zoomable", version.ref = "zoomable" } # lint dependencies lintApi = { module = "com.android.tools.lint:lint-api", version.ref = "lintApi" } +lintTests = { module = "com.android.tools.lint:lint-tests", version.ref = "lintApi" } # temporary bug workaround androidx-kmp-preview-bug1-workaround = { module = "androidx.customview:customview-poolingcontainer", version = "1.1.0" } diff --git a/hedvig-lint/build.gradle.kts b/hedvig-lint/build.gradle.kts index 585c1e0326..595239799e 100644 --- a/hedvig-lint/build.gradle.kts +++ b/hedvig-lint/build.gradle.kts @@ -9,6 +9,9 @@ plugins { dependencies { compileOnly(libs.lintApi) + testImplementation(libs.junit) + testImplementation(libs.lintApi) + testImplementation(libs.lintTests) } java { diff --git a/hedvig-lint/src/main/kotlin/com/hedvig/android/lint/HedvigLintRegistry.kt b/hedvig-lint/src/main/kotlin/com/hedvig/android/lint/HedvigLintRegistry.kt index bf5c2ddf08..f719a19c90 100644 --- a/hedvig-lint/src/main/kotlin/com/hedvig/android/lint/HedvigLintRegistry.kt +++ b/hedvig-lint/src/main/kotlin/com/hedvig/android/lint/HedvigLintRegistry.kt @@ -8,6 +8,7 @@ import com.android.tools.lint.detector.api.Issue class HedvigLintRegistry : IssueRegistry() { override val issues: List = listOf( Material2Detector.ISSUE, + NamespaceImportDetector.ISSUE, ) override val api: Int = CURRENT_API diff --git a/hedvig-lint/src/main/kotlin/com/hedvig/android/lint/NamespaceImportDetector.kt b/hedvig-lint/src/main/kotlin/com/hedvig/android/lint/NamespaceImportDetector.kt new file mode 100644 index 0000000000..b824c0ed7d --- /dev/null +++ b/hedvig-lint/src/main/kotlin/com/hedvig/android/lint/NamespaceImportDetector.kt @@ -0,0 +1,122 @@ +package com.hedvig.android.lint + +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.StringOption +import com.android.tools.lint.detector.api.isKotlin +import com.hedvig.android.lint.config.Priorities +import com.hedvig.android.lint.util.OptionLoadingDetector +import com.hedvig.android.lint.util.StringSetLintOption +import com.hedvig.android.lint.util.sourceImplementation +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UImportStatement + +private const val NamespaceImportDetectorIssueId = "NamespaceImport" + +/** + * Reports imports that shorten a qualified reference past the point where the short name still says + * what it is, such as `import hedvig.resources.Res.string` turning `Res.string.FOO` into `string.FOO`. + * + * Importing a type is fine, so a sealed subclass or enum entry (`HomeUiState.Success`) is left alone. + * Importing a member off a class owner is not, because the owner is what gave the name its meaning. + * A capitalized name that is still meaningless on its own, such as `Clock.System`, is covered by + * [DENY_LIST]. + */ +internal class NamespaceImportDetector + @JvmOverloads + constructor( + private val extraDeniedImports: StringSetLintOption = StringSetLintOption(DENY_LIST), + private val allowedImports: StringSetLintOption = StringSetLintOption(ALLOW_LIST), + ) : OptionLoadingDetector(extraDeniedImports, allowedImports), SourceCodeScanner { + override fun getApplicableUastTypes(): List> = listOf>( + UImportStatement::class.java, + ) + + override fun createUastHandler(context: JavaContext): UElementHandler? { + val language = context.uastFile?.lang ?: return null + if (!isKotlin(language)) return null + return object : UElementHandler() { + override fun visitImportStatement(node: UImportStatement) { + if (node.isOnDemand) return // Wildcards are owned by ktlint's no-wildcard-imports. + val importText = node.sourcePsi?.text ?: return + // An alias is a deliberate act of renaming, and gives the use site a name of its own. + if (importText.contains(" as ")) return + val qualifiedName = importText.removePrefix("import").trim() + + val importedName = qualifiedName.substringAfterLast('.') + val ownerPath = qualifiedName.substringBeforeLast('.', "") + val ownerName = ownerPath.substringAfterLast('.') + if (importedName.isEmpty() || ownerName.isEmpty()) return + // A lowercase owner is a package, so this is a plain top-level import. + if (!ownerName.first().isUpperCase()) return + // `Duration.Companion.seconds` and friends exist to enable the `5.seconds` receiver idiom. + if (ownerName == "Companion") return + // A capitalized path segment is not proof of a class: `platform.Foundation` is a package. + if (context.evaluator.findClass(ownerPath) == null) return + if (qualifiedName in allowedImports.value) return + + val importsAMember = importedName.first().isLowerCase() + val isDeniedByName = qualifiedName in DEFAULT_DENIED_IMPORTS || + qualifiedName in extraDeniedImports.value + if (!importsAMember && !isDeniedByName) return + + context.report( + issue = ISSUE, + location = context.getLocation(node), + message = "Import `$ownerName` and write `$ownerName.$importedName` at the use site. " + + "On its own, `$importedName` no longer says what it is.", + ) + } + } + } + + companion object { + /** + * Imports whose final segment is capitalized, so they read as a type, but which still leave + * nothing meaningful behind at the use site. + */ + private val DEFAULT_DENIED_IMPORTS = setOf( + "kotlin.time.Clock.System", + ) + + internal val DENY_LIST = StringOption( + "denied-member-imports", + "A comma-separated list of fully qualified imports to reject in addition to the built-in ones.", + null, + "This property should define a comma-separated list of fully qualified imports that must " + + "never be used, even though their final segment is capitalized", + ) + + internal val ALLOW_LIST = StringOption( + "allowed-member-imports", + "A comma-separated list of fully qualified member imports that should be allowed.", + null, + "This property should define a comma-separated list of fully qualified member imports that " + + "are allowed to shorten their receiver away", + ) + + val ISSUE = Issue.create( + id = NamespaceImportDetectorIssueId, + briefDescription = "Importing a member hides the receiver that carries its meaning", + explanation = """ + Import the type, never the namespace. An import may shorten a qualified reference only when \ + the short name still says what it is to someone reading that line cold. + + Sealed subclasses and enum entries pass that test, so `HomeUiState.Success` may be imported \ + as `Success`. Members reached through a receiver that carries the meaning do not: \ + `Res.string.FOO` must not become `string.FOO`, and `Clock.System.now()` must not become \ + `System.now()`, which additionally reads as `java.lang.System`. + """, + category = Category.CORRECTNESS, + priority = Priorities.NORMAL, + severity = Severity.ERROR, + implementation = sourceImplementation(), + ) + .setOptions(listOf(DENY_LIST, ALLOW_LIST)) + .setEnabledByDefault(true) + } + } diff --git a/hedvig-lint/src/test/kotlin/com/hedvig/android/lint/NamespaceImportDetectorTest.kt b/hedvig-lint/src/test/kotlin/com/hedvig/android/lint/NamespaceImportDetectorTest.kt new file mode 100644 index 0000000000..2e586d1b98 --- /dev/null +++ b/hedvig-lint/src/test/kotlin/com/hedvig/android/lint/NamespaceImportDetectorTest.kt @@ -0,0 +1,198 @@ +package com.hedvig.android.lint + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test + +class NamespaceImportDetectorTest : LintDetectorTest() { + override fun getDetector(): Detector = NamespaceImportDetector() + + override fun getIssues(): List = listOf(NamespaceImportDetector.ISSUE) + + private val resources = kotlin( + """ + package com.example.res + object Res { + object string { + const val GREETING = "hi" + } + object drawable + } + """, + ).indented() + + private val designSystem = kotlin( + """ + package com.example.ds + object TooltipDefaults { + val defaultStyle: Int = 0 + } + """, + ).indented() + + private val uiState = kotlin( + """ + package com.example.ui + sealed interface HomeUiState { + object Success : HomeUiState + object Loading : HomeUiState + } + """, + ).indented() + + private val duration = kotlin( + """ + package com.example.time + class Duration { + companion object { + val seconds: Int = 1 + } + } + """, + ).indented() + + // Kotlin/Native interop packages are capitalized, which a purely textual check misreads as a class. + private val capitalizedPackage = kotlin( + """ + package platform.Foundation + fun systemLocale(): String = "" + """, + ).indented() + + @Test + fun testReportsLowercaseMemberImport() { + lint() + .files( + resources, + kotlin( + """ + package com.example.app + import com.example.res.Res.string + fun greet() = string.GREETING + """, + ).indented(), + ) + .issues(NamespaceImportDetector.ISSUE) + .run() + .expectErrorCount(1) + .expectContains("Import Res and write Res.string at the use site") + } + + @Test + fun testReportsEveryOffendingImportInAFile() { + lint() + .files( + resources, + designSystem, + kotlin( + """ + package com.example.app + import com.example.ds.TooltipDefaults.defaultStyle + import com.example.res.Res.drawable + import com.example.res.Res.string + fun use() = listOf(string, drawable, defaultStyle) + """, + ).indented(), + ) + .issues(NamespaceImportDetector.ISSUE) + .run() + .expectErrorCount(3) + } + + @Test + fun testAllowsSealedSubclassImport() { + lint() + .files( + uiState, + kotlin( + """ + package com.example.app + import com.example.ui.HomeUiState + import com.example.ui.HomeUiState.Loading + import com.example.ui.HomeUiState.Success + fun describe(state: HomeUiState) = when (state) { + Success -> "ok" + Loading -> "wait" + } + """, + ).indented(), + ) + .issues(NamespaceImportDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun testAllowsCompanionExtensionImport() { + lint() + .files( + duration, + kotlin( + """ + package com.example.app + import com.example.time.Duration + import com.example.time.Duration.Companion.seconds + fun timeout() = Duration.seconds + """, + ).indented(), + ) + .issues(NamespaceImportDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun testIgnoresCapitalizedPackage() { + lint() + .files( + capitalizedPackage, + kotlin( + """ + package com.example.app + import platform.Foundation.systemLocale + fun locale() = systemLocale() + """, + ).indented(), + ) + .issues(NamespaceImportDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun testAllowsAliasedImport() { + lint() + .files( + resources, + kotlin( + """ + package com.example.app + import com.example.res.Res.string as StringResources + fun greet() = StringResources.GREETING + """, + ).indented(), + ) + .issues(NamespaceImportDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun testIgnoresWildcardImport() { + lint() + .files( + resources, + kotlin( + """ + package com.example.app + import com.example.res.* + fun greet() = Res.string.GREETING + """, + ).indented(), + ) + .issues(NamespaceImportDetector.ISSUE) + .run() + .expectClean() + } +}