Skip to main content

UI Components & Theme System

This document covers the Visla GPS Android app's custom design system built on Jetpack Compose, including the VislaTheme, color palettes, typography scale, dimension tokens, glassmorphism effects, and the full component library.


Theme Architecture​

VislaGPSTheme Composition​

The app wraps Material3's MaterialTheme with a custom VislaGPSTheme that provides domain-specific tokens via CompositionLocalProvider. This is the top-level theme composable applied at the app root:

// Theme.kt
@Composable
fun VislaGPSTheme(
darkTheme: Boolean = true,
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColorScheme else LightColorScheme

val materialColorScheme = if (darkTheme) {
darkColorScheme(
primary = colors.primary,
secondary = colors.secondary,
// ... mapped to material tokens
)
} else {
lightColorScheme(/* ... */)
}

CompositionLocalProvider(
LocalColors provides colors,
LocalDarkTheme provides darkTheme,
LocalTypography provides DefaultTypography
) {
MaterialTheme(
colorScheme = materialColorScheme,
content = content
)
}
}

Three composition locals are provided:

LocalTypePurpose
LocalColorsColorSchemeFull semantic color palette
LocalDarkThemeBooleanCurrent dark/light state
LocalTypographyTypographyCustom typography scale

Components access these through the VislaTheme object:

// Usage in any @Composable
val bgColor = VislaTheme.colors.background
val headlineStyle = VislaTheme.typography.headlineLarge
val isDark = VislaTheme.isDarkTheme

Dark Mode Support​

Theme mode is controlled by the ThemeMode enum persisted in AppPreferencesDataStore:

enum class ThemeMode {
SYSTEM, // Follow system dark/light setting
DARK, // Force dark theme
LIGHT; // Force light theme
}

The shouldUseDarkTheme() composable resolves the mode:

@Composable
fun shouldUseDarkTheme(themeMode: ThemeMode): Boolean {
return when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
}
}

Users can switch themes via ThemePickerDialog in settings.


Color System​

Brand Colors​

object BrandColors {
val primary = Color(0xFF004AAD) // Visla blue
val secondary = Color(0xFF5DE0E6) // Visla cyan
val gradient = Brush.linearGradient(colors = listOf(primary, secondary))
}

ColorScheme Data Class​

The ColorScheme data class is marked @Immutable and organizes 50+ semantic color tokens into logical groups:

GroupTokensPurpose
Brandprimary, primaryVariant, onPrimary, secondary, onSecondary, tertiaryCore brand identity
Backgroundbackground, backgroundSecondary, backgroundTertiaryScreen/section backgrounds
Surfacesurface, surfaceElevated, surfaceGlass, surfaceSubtle, onSurfaceCard/container surfaces
TexttextPrimary, textSecondary, textTertiary, textMuted, textLabelText hierarchy (100% β†’ 20% opacity)
Statussuccess, warning, error, info + containersFeedback indicators
Interactiveinteractive, interactiveDisabled, interactivePressedButton/link states
ForminputBackground, inputBorder, inputBorderFocusedInput field states
Dividersdivider, border, borderSubtleSeparators and borders
SocialsocialFacebook, socialGoogle, socialApple + icon variantsOAuth provider branding
Device StatusdeviceOnline, deviceOffline, deviceSuspendedGPS tracker state
MapmapRoute, mapStart, mapEndRoute visualization
GradientsgradientStart, gradientEndBackground gradients

Dark Palette​

primary:            #3366FF     (bright blue)
background: #0D0D1A (near-black navy)
backgroundSecondary:#1A1A2E (dark navy)
surface: #1A1A2E (dark navy)
surfaceElevated: #252542 (raised purple-navy)
surfaceGlass: #FFFFFF14 (8% white)
textPrimary: #FFFFFF (white)
textSecondary: #FFFFFFB3 (70% white)
textTertiary: #FFFFFF80 (50% white)
deviceOnline: #4CAF50 (green)
deviceOffline: #E53935 (red)
deviceSuspended: #FF9800 (orange)

Light Palette​

primary:            #004AAD     (Visla blue)
background: #FFFBFE (warm white)
backgroundSecondary:#F5F5F7 (light gray)
surface: #FFFFFF (white)
surfaceElevated: #FFFFFF (white)
surfaceGlass: #0000000D (5% black)
textPrimary: #1C1B1F (near-black)
textSecondary: #00000099 (60% black)
textTertiary: #00000066 (40% black)
deviceOnline: #2E7D32 (dark green)
deviceOffline: #C62828 (dark red)
deviceSuspended: #E65100 (dark orange)

Gradients​

The Gradients object provides theme-aware gradient brushes:

object Gradients {
@Composable fun backgroundVertical(): Brush // gradientStart β†’ gradientEnd
@Composable fun backgroundVertical3(): Brush // background β†’ secondary β†’ tertiary
@Composable fun primaryHorizontal(): Brush // primary β†’ primary(0.7f)
@Composable fun profileAvatar(): Brush // primary β†’ secondary
fun badgeGold(): Brush // #FFD700 β†’ #FFA500
val brand: Brush = BrandColors.gradient // Visla blue β†’ cyan
}

Typography Scale​

The custom Typography data class provides 20 text styles organized into seven categories, all using FontFamily.Default:

CategoryStyleSizeWeightLine HeightUse Case
DisplaydisplayLarge40spBold48spHero numbers, splash
displayMedium32spBold40spPage titles
displaySmall28spBold36spSection titles
HeadlineheadlineLarge24spBold32spScreen headers
headlineMedium20spBold28spCard titles
headlineSmall18spBold24spSub-section headers
TitletitleLarge16spSemiBold24spList item titles
titleMedium14spSemiBold20spSecondary titles
titleSmall12spSemiBold16spCompact titles
BodybodyLarge16spNormal24spPrimary content
bodyMedium14spNormal20spStandard body text
bodySmall12spNormal16spSecondary content
LabellabelLarge14spMedium20spButton text
labelMedium12spMedium16spInput labels
labelSmall11spMedium14spBadges, tags
CaptioncaptionLarge12spNormal16spTimestamps
captionMedium11spNormal14spHelper text
captionSmall10spNormal12spFine print
CodecodeInput24spMedium32spOTP digit input (2sp tracking)
codeDisplay16spMedium24spCode/token display (8sp tracking)

Usage:

Text(
text = "Device Tracker",
style = VislaTheme.typography.headlineLarge,
color = VislaTheme.colors.textPrimary
)

Dimensions​

The Dimensions object is the single source of truth for all spatial values, built on a 4dp grid system with semantic naming:

Spacing​

Dimensions.Spacing.none   // 0.dp
Dimensions.Spacing.xxs // 2.dp – Micro
Dimensions.Spacing.xs // 4.dp – Tight
Dimensions.Spacing.sm // 8.dp – Small
Dimensions.Spacing.md // 12.dp – Medium
Dimensions.Spacing.lg // 16.dp – Standard
Dimensions.Spacing.xl // 20.dp – Large
Dimensions.Spacing.xxl // 24.dp – Extra large
Dimensions.Spacing.xxxl // 32.dp – Section
Dimensions.Spacing.huge // 40.dp – Major section
Dimensions.Spacing.massive// 48.dp – Screen-level

Corner Radius​

Dimensions.Radius.none    // 0.dp
Dimensions.Radius.xs // 4.dp – Subtle
Dimensions.Radius.sm // 6.dp – Small
Dimensions.Radius.md // 8.dp – Medium
Dimensions.Radius.lg // 12.dp – Standard card
Dimensions.Radius.xl // 14.dp – Large
Dimensions.Radius.xxl // 16.dp – Extra large
Dimensions.Radius.xxxl // 20.dp – Full card
Dimensions.Radius.full // 24.dp – Near-circular

Icon Sizes​

Dimensions.IconSize.xs           // 14.dp  – Micro icons
Dimensions.IconSize.sm // 16.dp – Small icons
Dimensions.IconSize.md // 20.dp – Medium icons
Dimensions.IconSize.lg // 24.dp – Standard icons
Dimensions.IconSize.xl // 28.dp – Large icons
Dimensions.IconSize.xxl // 32.dp – Extra large
Dimensions.IconSize.display // 40.dp – Display icons
Dimensions.IconSize.hero // 48.dp – Hero icons
Dimensions.IconSize.avatar // 56.dp – Avatar size
Dimensions.IconSize.illustration // 80.dp – Illustrations
Dimensions.IconSize.feature // 100.dp – Feature icons

Component Heights​

Dimensions.Height.buttonSm  // 40.dp  – Small button
Dimensions.Height.button // 48.dp – Standard button
Dimensions.Height.buttonLg // 56.dp – Large button
Dimensions.Height.buttonXl // 60.dp – Extra large button
Dimensions.Height.input // 56.dp – Input field
Dimensions.Height.inputLg // 60.dp – Large input field
Dimensions.Height.listItem // 56.dp – List item
Dimensions.Height.header // 56.dp – Header/toolbar

Other Token Groups​

// Borders
Dimensions.Border.none // 0.dp
Dimensions.Border.thin // 1.dp
Dimensions.Border.medium // 2.dp
Dimensions.Border.thick // 3.dp

// Elevation
Dimensions.Elevation.none // 0.dp
Dimensions.Elevation.sm // 2.dp
Dimensions.Elevation.md // 4.dp
Dimensions.Elevation.lg // 8.dp
Dimensions.Elevation.xl // 16.dp

// Navigation
Dimensions.NavBar.height // 60.dp

// Bottom Sheet
Dimensions.BottomSheet.dragHandleHeight // 24.dp
Dimensions.BottomSheet.dragHandleWidth // 28.dp
Dimensions.BottomSheet.dragHandleThickness // 3.dp
Dimensions.BottomSheet.topCornerRadius // 16.dp
Dimensions.BottomSheet.headerHeight // 40.dp
Dimensions.BottomSheet.EXPANDED_FRACTION // 0.90f
Dimensions.BottomSheet.HALF_FRACTION // 0.50f

// List Item
Dimensions.ListItem.leadingIconSm // 44.dp
Dimensions.ListItem.leadingIconMd // 48.dp
Dimensions.ListItem.leadingIconLg // 50.dp
Dimensions.ListItem.muteBadgeSize // 18.dp
Dimensions.ListItem.muteBadgeIconSize // 12.dp
Dimensions.ListItem.statusDotSize // 6.dp

// Map Markers
Dimensions.Marker.size // 32.dp
Dimensions.Marker.glowSize // 44.dp
Dimensions.Marker.iconSize // 16.dp
Dimensions.Marker.navigationIconSize // 18.dp

GlassSurface​

The GlassSurface composable provides a frosted-glass effect for components that float over busy backgrounds (e.g., map overlays):

@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
shape: Shape,
content: @Composable () -> Unit,
)

Implementation details:

  • Shadow: 2dp shadow applied to the shape for depth.
  • Background: surfaceElevated at 92% opacity for the frosted effect.
  • Border: 0.5dp borderSubtle stroke for edge definition.
  • Clipping: Shape is clipped before the caller's modifier so ripples respect the shape bounds.

Usage:

GlassSurface(shape = RoundedCornerShape(Dimensions.Radius.lg)) {
Text("Floating over the map", color = VislaTheme.colors.textPrimary)
}

Map Constants​

MapConstants centralizes map behavior values shared across Google Maps and Mapbox providers:

object MapConstants {
const val FOCUS_ZOOM = 16.0 // Device/location focus zoom
const val DEFAULT_ZOOM = 14.0 // General default zoom
const val FIT_ALL_PADDING = 100.0 // Padding when fitting all devices (dp)
const val PUCK_CLICK_THRESHOLD = 60.0 // Tap detection radius (px)
const val NOTIFICATION_ZOOM = 16f // Deep link zoom
const val DEFAULT_LAT = 42.5 // Default center (Italy)
const val DEFAULT_LNG = 12.5
const val DEFAULT_OVERVIEW_ZOOM = 5.5 // Overview zoom
}

Component Inventory​

Design System Types (ComponentTypes.kt)​

Shared enums used across the component library:

enum class BackgroundVariant { Default, Premium }
enum class ButtonSize { Small, Medium, Large, XLarge }
enum class LoadingSize { Small, Medium, Large }

Common Components (components/common/)​

ComponentDescription
GradientBackgroundFull-screen gradient wrapper supporting Default and Premium background variants
PrimaryButtonSolid primary action button with loading spinner, 4 size options, optional icon
SecondaryButtonOutlined secondary button matching PrimaryButton interface
ActionButtonCompact button with icon for inline actions and empty state CTAs
CircleIconButtonCircular icon button with configurable container/content colors
TextInputFull-featured text field with validation, error state, password masking, keyboard options
OtpInput6-digit code input with individual digit boxes and auto-focus
StatusBadgeOnline/offline/suspended indicator with colored dot and semi-transparent background
ContentCardStandard card container with surfaceElevated background and rounded corners
ElevatedCardClickable card variant with optional onClick handler
VislaListItemFlexible list row with leading/trailing content, customizable colors and typography
CircleIconLeadingHelper: circular icon container for VislaListItem leading slot
ChevronTrailingHelper: chevron icon for VislaListItem trailing slot
ScreenHeaderTop-level screen header with displaySmall title and optional actions
SubScreenHeaderNested screen header with back button and centered title
SectionHeaderSection divider with uppercase label, optional indicator dot and action
EmptyStateFull-screen empty state with icon, title, message, and optional action
ErrorMessageError display with optional retry button
LoadingIndicatorCentered circular progress in 3 sizes
AuthLogoTheme-adaptive Visla logo for auth screens
AuthSettingsHeaderAuth screen header with theme/language toggles
DeviceMarkerUnified map marker for devices, geofences, routes, and history points
DismissKeyboardBoxContainer that dismisses keyboard on tap
RowPosition / getRowShapeUtility enum + function for rounded corners in grouped list items

Device Components (components/device/)​

ComponentDescription
DeviceOverviewCardCard showing device name, model, and online/offline status
PositionCardCard displaying speed, altitude, direction, and address
QuickActionsCardDynamic action buttons (History, Sharing, Geofences, Settings) based on permissions
QuickActionButtonIndividual quick action with icon and label
CommandsSectionAvailable device commands with chip layout
SendCommandBottomSheetCategorized command list (Security, Tracking, Diagnostics)
CommandParameterSheetDynamic parameter input for device commands
NotificationsSectionPush, email, and call notification settings with contact management
SharingSectionDevice sharing information display
DeviceSettingsSectionDevice name editing and detail display
DetailSectionContainer for organizing detail rows with title
DetailStatRowIcon + label + value row for device statistics
CopyableRowLabel-value row with clipboard copy functionality
DangerButtonDestructive action button (suspend, remove device)
MiniMapCardEmbedded map preview of device location
AddDeviceBottomSheetModal for claiming devices via QR scan or token input

Map Components (components/map/)​

ComponentDescription
MapBottomSheetDraggable bottom sheet with device list, device info, and notification panels
MapSheetContentContent views for the bottom sheet (DeviceInfoContent, NotificationInfoContent)
MapControlsMap control buttons and online device count badge
EdgeClampedMarkersOff-screen device indicators clamped to screen edges
SheetAnchorEnum defining sheet positions: Collapsed, HalfExpanded, Expanded

Settings Components (components/settings/)​

ComponentDescription
SettingsSectionContainer with uppercase title for grouping settings rows
SettingsRowStatic row with icon, label, and optional trailing content
SettingsRowClickableClickable settings row with chevron
SettingsRowWithSubtitleSettings row with subtitle text
ActionRowDestructive/action row (e.g., logout)
ProfileHeaderUser profile section with avatar, name, and email
LogoutDialogConfirmation dialog for logout
LanguagePickerDialogLanguage selection bottom sheet
ThemePickerDialogTheme mode selection (System, Dark, Light)
TimezonePickerDialogTimezone picker with scrollable list

Geofence Components (components/geofence/)​

ComponentDescription
EnterDetailsPhaseGeofence creation form (name, description, alert settings)
DrawShapePhaseMap integration for drawing circle/polygon geofences
ShapeTypeButtonToggle between circle and polygon shapes

Notification Components (components/notifications/)​

ComponentDescription
EventCardCard for event-type notifications with icon and metadata
NotificationCardStandard notification card with formatted content
GeofenceCardGeofence alert card with enter/exit information
NotificationHistoryCardHistorical notification entry
SwipeToDeleteContainerSwipe gesture wrapper for dismissing notifications
EmptyEventsViewEmpty state for events tab
EmptyNotificationsViewEmpty state for notifications tab
NotificationsTabBackendPaginated notification list with swipe-to-dismiss

Subscription Components (components/subscription/)​

ComponentDescription
PremiumHeaderSubscription flow header with icon and close button
StatusCardCurrent subscription status display
ActiveSubscriptionContentFull active subscription layout (status + license + actions)
LicenseUsageCardLicense count with progress bar (active/allowed/available)
DeviceSelectorCardDevice count selector for plan configuration
PeriodSelectorCardBilling period selector with current plan highlighting
FeaturesCardFeature list with checkmarks
PriceSummaryCardPrice display for selected plan
PurchaseButtonPurchase CTA button
RestorePurchasesButtonRestore previous purchases
ActionsCardPlan modification and cancellation actions
InfoCardSubscription information card
NoSubscriptionContentEmpty state when no active subscription

Other Components​

ComponentFileDescription
DeviceSelectionDialogcomponents/DeviceSelectionDialog.ktModal dialog for selecting a device from a list

Component Patterns​

Modifier Parameter Ordering​

All components follow the Compose convention of accepting modifier as the first optional parameter after required parameters:

@Composable
fun PrimaryButton(
text: String, // Required
onClick: () -> Unit, // Required
modifier: Modifier = Modifier, // Optional, early position
icon: ImageVector? = null,
enabled: Boolean = true,
loading: Boolean = false,
size: ButtonSize = ButtonSize.Large,
fullWidth: Boolean = true,
testTag: String? = null,
)

Theme Access​

Components access the theme exclusively through VislaTheme:

@Composable
fun SectionHeader(title: String, ...) {
Text(
text = title.uppercase(),
style = VislaTheme.typography.labelMedium,
color = VislaTheme.colors.textSecondary,
)
}

Never access LocalColors.current or LocalTypography.current directly in components β€” always go through VislaTheme.colors, VislaTheme.typography, or VislaTheme.isDarkTheme.

Dimensions Usage​

Spacing and sizing always reference Dimensions tokens rather than raw dp values:

Column(
modifier = Modifier.padding(Dimensions.Spacing.lg),
verticalArrangement = Arrangement.spacedBy(Dimensions.Spacing.sm)
) {
// Content with consistent spacing
}

Status Colors​

Device status colors are resolved from the theme for automatic dark/light adaptation:

val color = when {
suspended -> VislaTheme.colors.deviceSuspended
isOnline -> VislaTheme.colors.deviceOnline
else -> VislaTheme.colors.deviceOffline
}

List Item Grouping with RowPosition​

Grouped list items use RowPosition and getRowShape() for proper corner rounding:

enum class RowPosition { SINGLE, FIRST, MIDDLE, LAST }

// Only SINGLE and FIRST get top corners rounded
// Only SINGLE and LAST get bottom corners rounded
val shape = getRowShape(position, Dimensions.Radius.lg)

Design Decisions​

Why Custom VislaTheme Over Raw MaterialTheme​

Material3's ColorScheme provides generic tokens like primary, surface, and error, but lacks domain-specific semantics. Visla's ColorScheme adds:

  • Device status colors (deviceOnline, deviceOffline, deviceSuspended) β€” GPS tracker states that have no Material equivalent.
  • Map colors (mapRoute, mapStart, mapEnd) β€” route visualization tokens.
  • Social provider branding (socialFacebook, socialGoogle, socialApple) β€” OAuth button colors.
  • Fine-grained text hierarchy β€” five levels (textPrimary through textMuted) instead of Material's two (onSurface, onSurfaceVariant).
  • Glass/transparent surfaces (surfaceGlass, surfaceSubtle) β€” for map overlays and frosted effects.

The custom theme wraps MaterialTheme so that Material3 components (dialogs, bottom sheets) still receive correct colors, while Visla components use the richer token set.

Why Dimensions Object​

Hardcoded dp values scattered across components lead to subtle inconsistencies (one card uses 12.dp padding while another uses 16.dp for the same context). The Dimensions object provides:

  • Single source of truth β€” change Spacing.lg from 16.dp to 20.dp and every component updates.
  • Semantic naming β€” Spacing.lg communicates intent better than 16.dp.
  • 4dp grid enforcement β€” all values are multiples of 4dp (except xxs = 2.dp), ensuring alignment.
  • Categorized tokens β€” separate objects for Spacing, Radius, IconSize, Height, Border, and Elevation prevent misuse (you won't accidentally use a spacing value for a corner radius).

Why Glassmorphism Effects​

The GlassSurface composable uses a semi-transparent surface with subtle border and shadow to create a frosted-glass aesthetic. This matters because:

  • Map overlays need visibility without occlusion β€” fully opaque controls hide the map; fully transparent controls are invisible. The 92% opacity strikes the right balance.
  • Modern design language β€” glassmorphism provides depth cues and visual hierarchy consistent with iOS and contemporary Android apps.
  • Theme integration β€” the effect adapts to dark/light mode via surfaceElevated and borderSubtle tokens rather than hardcoded colors.

Why Composition Locals for Theme​

Using CompositionLocalProvider to deliver theme values is the Compose-idiomatic pattern. Compared to alternatives:

  • No prop drilling β€” components read VislaTheme.colors anywhere in the tree without passing colors through every intermediate composable.
  • Automatic recomposition β€” when the theme changes (dark ↔ light), only affected composables recompose.
  • staticCompositionLocalOf β€” used instead of compositionLocalOf because theme values rarely change during a session, avoiding unnecessary snapshot overhead.
  • Familiar pattern β€” mirrors how Material3 itself provides MaterialTheme.colorScheme, so the approach is immediately understandable to any Compose developer.