import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/theme_controller.dart'; import '../../data/models/slot_assignment.dart'; import '../../data/models/slot_type.dart'; import '../apps/all_apps_screen.dart'; import '../apps/app_picker_sheet.dart'; import '../jellyfin/jellyfin_dashboard_screen.dart'; import '../jellyfin/jellyfin_playback_controller.dart'; import '../settings/settings_screen.dart'; import 'gps_speed_controller.dart'; import 'home_media_state.dart'; import 'launcher_controller.dart'; import 'obd_controller.dart'; import 'pinned_app_actions.dart'; import 'system_controller.dart'; import 'telemetry_settings_controller.dart'; import 'weather_controller.dart'; import '../settings/update_controller.dart'; import 'widgets/clock_block.dart'; import 'widgets/gps_speed_card.dart'; import 'widgets/home_hint.dart'; import 'widgets/night_mode_overlay.dart'; import 'widgets/obd_card.dart'; import 'widgets/shortcut_grid.dart'; import 'widgets/status_cluster.dart'; import 'widgets/volume_card.dart'; /// The only home page. No app bar, no dock, no pages: information, the main /// volume control, and up to 16 configurable shortcuts. class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { bool _nightMode = false; void _enterNightMode() { setState(() => _nightMode = true); } void _leaveNightMode() { setState(() => _nightMode = false); } @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final launcher = context.watch(); final system = context.watch(); final appearance = context.watch(); final obd = context.watch(); final gps = context.watch(); final weather = context.watch(); final telemetrySettings = context.watch(); final update = context.watch(); final jellyfinPlayback = context.watch(); final screen = MediaQuery.sizeOf(context); final compact = screen.height < 700 || screen.width < 1100; final horizontalPadding = compact ? 30.0 : 44.0; final denseHome = launcher.homeButtonCount > 8; final leftWidth = denseHome ? (screen.width * 0.31).clamp(300.0, 400.0) : (screen.width * 0.37).clamp(300.0, 440.0); final gap = compact ? 26.0 : 40.0; final obdTemperature = obd.ambientCelsius; final useObdTemperature = telemetrySettings.temperatureUseObd && obdTemperature != null; final temperature = !telemetrySettings.temperatureEnabled ? null : useObdTemperature ? obdTemperature : (weather.hasTemperature ? weather.temperatureCelsius : null); final temperatureText = temperature == null ? null : useObdTemperature ? 'Outside, ${temperature.round()}°C' : '${weather.locationName}, ${temperature.round()}°C'; final temperatureAlert = temperature != null && temperature < 3; final showObdSpeed = telemetrySettings.speedEnabled && telemetrySettings.speedUseObd && obd.hasTelemetry; final showGpsSpeed = telemetrySettings.speedEnabled && !showObdSpeed && gps.hasSpeed; final jellyfinPlaying = jellyfinPlayback.playing; final systemMediaIsExternal = isExternalSystemMedia(system.media); final otherMusicPlaying = shouldShowMusicEqualizer( systemMusicPlaying: system.isMusicPlaying, jellyfinPlaying: jellyfinPlaying, systemMedia: system.media, ); ShortcutSpec slotSpec(SlotType slot) { final assignment = launcher.assignmentFor(slot); final playing = slot == SlotType.music && otherMusicPlaying && assignment != null; return ShortcutSpec( title: slot.label, subtitle: assignment?.label ?? 'Select app', iconAsset: slot.iconAsset, showEqualizer: playing, assigned: assignment != null, onTap: () => _openSlot(context, slot), onLongPress: () => showAppPicker(context, slot), longPressHint: 'change the app', ); } ShortcutSpec jellyfinSpec() { final album = jellyfinPlayback.nowPlayingAlbum; final artist = jellyfinPlayback.nowPlayingArtist; final subtitle = jellyfinPlayback.isActive && album != null ? (artist != null && artist.isNotEmpty ? '$artist — $album' : album) : 'Music library'; return ShortcutSpec( title: 'Jellyfin', subtitle: subtitle, iconAsset: 'assets/icons/headphones.svg', assigned: true, showEqualizer: jellyfinPlaying, onTap: () => _openJellyfin(context), ); } ShortcutSpec pinnedSpec(SlotAssignment pinned) { final app = launcher.appForAssignment(pinned); final customIcon = pinned.iconAsset; return ShortcutSpec( title: pinned.label.isEmpty ? pinned.packageName : pinned.label, subtitle: null, iconAsset: customIcon ?? 'assets/icons/app-window.svg', iconBytes: customIcon == null ? app?.icon : null, assigned: true, onTap: () => _openPinned(context, pinned), onLongPress: () => showPinnedAppMenu(context, pinned), longPressHint: 'open app options', ); } final shortcuts = [ for (final slot in SlotType.values) if (launcher.isSlotEnabled(slot)) slot == SlotType.jellyfin ? jellyfinSpec() : slotSpec(slot), for (final pinned in launcher.pinnedApps) pinnedSpec(pinned), ShortcutSpec( title: 'All apps', subtitle: 'Installed apps', iconAsset: 'assets/icons/layout-grid.svg', assigned: true, onTap: () => _openAllApps(context), ), ]; final otherSystemMusicPlaying = system.isMusicPlaying && systemMediaIsExternal; final jellyfinActive = jellyfinPlayback.isActive && (jellyfinPlaying || !otherSystemMusicPlaying); final cardMedia = jellyfinActive ? jellyfinPlayback.mediaPlayback : system.media; final cardPlaying = jellyfinActive ? jellyfinPlaying : system.isMusicPlaying; return PopScope( canPop: false, child: Scaffold( backgroundColor: colors.canvas, body: Stack( children: [ Positioned.fill( child: DecoratedBox( decoration: homeBackground( colors, Theme.of(context).brightness, ), ), ), if (!_nightMode) Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onLongPress: _enterNightMode, ), ), const _FirstRunLauncherTip(), Positioned( top: -190, right: -120, child: IgnorePointer( child: Container( width: 560, height: 560, decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ colors.accent.withValues( alpha: Theme.of(context).brightness == Brightness.dark ? 0.16 : 0.10, ), colors.accent.withValues(alpha: 0), ], ), ), ), ), ), SafeArea( minimum: EdgeInsets.fromLTRB( horizontalPadding, compact ? 18 : 24, horizontalPadding, compact ? 14 : 18, ), child: SizedBox.expand( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClockBlock( compact: compact, temperatureText: temperatureText, temperatureAlert: temperatureAlert, ), const Spacer(), StatusCluster( status: system.status, compact: compact, showBattery: appearance.showBattery, onOpenSettings: () => _openSettings(context), showUpdateAvailable: update.updateAvailable, ), ], ), SizedBox(height: compact ? 16 : 24), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: leftWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.end, children: [ if (showObdSpeed) ...[ ObdCard( speedKmh: obd.speedKmh, throttlePercent: obd.throttlePercent, economyL100: obd.economyL100, compact: compact, ), SizedBox(height: compact ? 12 : 14), ] else if (showGpsSpeed) ...[ GpsSpeedCard( speedKmh: gps.speedKmh ?? 0, compact: compact, ), SizedBox(height: compact ? 12 : 14), ], VolumeCard( volume: system.volume, compact: compact, onSetVolume: system.setVolume, onToggleMute: system.toggleMute, isMusicPlaying: cardPlaying, onPlayPause: jellyfinActive ? () => unawaited(jellyfinPlayback.toggle()) : () => unawaited(_toggleMusic(context)), media: cardMedia, diagnostics: system.volumeDiagnostics, onToggleDiagnostics: system.toggleVolumeDiagnostics, unconfirmed: system.volumeUnconfirmed, ), ], ), ), SizedBox(width: gap), Expanded( child: Column( children: [ Expanded( child: ShortcutGrid( shortcuts: shortcuts, compact: compact, ), ), if (!compact && launcher.firstLaunchAt != null) ...[ const SizedBox(height: 4), HomeHint( firstLaunchAt: launcher.firstLaunchAt!, text: launcher.pinnedApps.isEmpty ? 'Hold a shortcut to change the app' : 'Hold a slot to change · hold an added app for options', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkFaint, ), ), ], ], ), ), ], ), ), ], ), ), ), if (_nightMode) Positioned.fill( child: NightModeOverlay(onDismiss: _leaveNightMode), ), ], ), ), ); } Future _openPinned( BuildContext context, SlotAssignment assignment, ) async { final error = await context.read().launchAssignment( assignment, ); if (error != null && context.mounted) { _showMessage(context, error); } } Future _toggleMusic(BuildContext context) async { final system = context.read(); final launcher = context.read(); if (system.isMusicPlaying) { await system.pauseMusic(); return; } // A media play key resumes a paused session. If nothing responds, open the // app assigned to the Music slot (or ask the user to choose one). final started = await system.playMusic(); if (started || !context.mounted) return; final assignment = launcher.assignmentFor(SlotType.music); if (assignment == null) { await showAppPicker(context, SlotType.music); return; } final error = await launcher.launchSlot(SlotType.music); if (error != null && context.mounted) { _showMessage(context, error); } } Future _openSlot(BuildContext context, SlotType slot) async { final launcher = context.read(); if (launcher.assignmentFor(slot) == null) { await showAppPicker(context, slot); return; } final error = await launcher.launchSlot(slot); if (error != null && context.mounted) { _showMessage(context, error); } } void _openSettings(BuildContext context) { final playback = context.read(); Navigator.of(context).push( MaterialPageRoute( builder: (_) => SettingsScreen(playback: playback), ), ); } void _openAllApps(BuildContext context) { Navigator.of(context) .push(MaterialPageRoute(builder: (_) => const AllAppsScreen())); } void _openJellyfin(BuildContext context) { final playback = context.read(); final album = playback.isActive ? playback.currentAlbum : null; Navigator.of(context).push( MaterialPageRoute( builder: (_) => JellyfinDashboardScreen(openAlbum: album), ), ); } void _showMessage(BuildContext context, String message) { final messenger = ScaffoldMessenger.of(context); messenger.hideCurrentSnackBar(); messenger.showSnackBar(SnackBar(content: Text(message))); } } class _FirstRunLauncherTip extends StatefulWidget { const _FirstRunLauncherTip(); @override State<_FirstRunLauncherTip> createState() => _FirstRunLauncherTipState(); } class _FirstRunLauncherTipState extends State<_FirstRunLauncherTip> { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; final launcher = context.read(); if (launcher.homeRoleHintShown) return; await launcher.markHomeRoleHintShown(); if (!mounted) return; final messenger = ScaffoldMessenger.of(context); messenger.hideCurrentSnackBar(); messenger.showSnackBar( const SnackBar( content: Text( 'You can set CarDash as your default launcher in Settings.', ), duration: Duration(seconds: 5), ), ); }); } @override Widget build(BuildContext context) => const SizedBox.shrink(); }