diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bf6c0561c..3477b27ad 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,83 +1,84 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - PrioBike-HH - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - FirebaseAppDelegateProxyEnabled - - ITSAppUsesNonExemptEncryption - - LSApplicationCategoryType - public.app-category.navigation - LSRequiresIPhoneOS - - NSAllowArbitraryLoads - - NSLocationWhenInUseUsageDescription - Die App benötigt den Standort zur Navigation. - NSLocationAlwaysAndWhenInUseUsageDescription - Die App benötigt den Standort zur Navigation. - UIBackgroundModes - - fetch - remote-notification - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSLocalNetworkUsageDescription - Diese App nutzt TCP-Netzwerkdienste, um Verbindungen zu Ampeln herzustellen, und braucht hierfür Deine Erlaubnis. - NSBonjourServices - - mqtt.tcp - - UIApplicationSupportsIndirectInputEvents - - FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED - - NSCameraUsageDescription - Diese App nutzt die Kamera, um QR-Codes zu scannen. - LSApplicationQueriesSchemes - - https - mailto - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + PrioBike-HH + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED + + FirebaseAppDelegateProxyEnabled + + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.navigation + LSApplicationQueriesSchemes + + https + mailto + + LSRequiresIPhoneOS + + NSAllowArbitraryLoads + + NSBonjourServices + + mqtt.tcp + + NSCameraUsageDescription + Diese App nutzt die Kamera, um QR-Codes zu scannen. + NSLocalNetworkUsageDescription + Diese App nutzt TCP-Netzwerkdienste, um Verbindungen zu Ampeln herzustellen, und braucht hierfür deine Erlaubnis. + NSLocationAlwaysAndWhenInUseUsageDescription + Die App benötigt den Standort zur Navigation. + NSLocationWhenInUseUsageDescription + Die App benötigt den Standort zur Navigation. + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + + diff --git a/lib/common/layout/dialog.dart b/lib/common/layout/dialog.dart index 097be53f9..4991800d7 100644 --- a/lib/common/layout/dialog.dart +++ b/lib/common/layout/dialog.dart @@ -208,6 +208,8 @@ class DialogLayoutState extends State with WidgetsBindingObserver // Initial state of the bottom padding. paddingBottom = MediaQuery.of(context).viewInsets.bottom; + final orientation = MediaQuery.of(context).orientation; + return AnimatedPadding( padding: EdgeInsets.only(bottom: paddingBottom), duration: const Duration(milliseconds: 200), @@ -220,39 +222,43 @@ class DialogLayoutState extends State with WidgetsBindingObserver child: Material( color: Colors.transparent, child: Container( - width: MediaQuery.of(context).size.width * 0.8, + width: orientation == Orientation.portrait + ? MediaQuery.of(context).size.width * 0.8 + : MediaQuery.of(context).size.width * 0.6, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(24)), color: Theme.of(context).colorScheme.background.withOpacity(0.6), ), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.icon != null) - Icon( - widget.icon!, - color: widget.iconColor ?? Theme.of(context).colorScheme.primary, - size: 36, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icon != null) + Icon( + widget.icon!, + color: widget.iconColor ?? Theme.of(context).colorScheme.primary, + size: 36, + ), + if (widget.icon != null) const SmallVSpace(), + BoldSubHeader( + context: context, + text: widget.title, + textAlign: TextAlign.center, ), - if (widget.icon != null) const SmallVSpace(), - BoldSubHeader( - context: context, - text: widget.title, - textAlign: TextAlign.center, - ), - const SmallVSpace(), - Content( - context: context, - text: widget.text, - textAlign: TextAlign.center, - ), - if (widget.actions != null) ...[ const SmallVSpace(), - ...actions, - ] - ], + Content( + context: context, + text: widget.text, + textAlign: TextAlign.center, + ), + if (widget.actions != null) ...[ + const SmallVSpace(), + ...actions, + ] + ], + ), ), ), ), diff --git a/lib/loader.dart b/lib/loader.dart index 7c85b3b61..36cfeb0fd 100644 --- a/lib/loader.dart +++ b/lib/loader.dart @@ -94,6 +94,11 @@ class LoaderState extends State { await MapboxTileImageCache.pruneUnusedImages(); getIt().sendUnsentElements(); + // Only allow portrait mode. + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + settings.incrementUseCounter(); } catch (e, stacktrace) { log.e("Error while loading services $e\n $stacktrace"); diff --git a/lib/ride/views/finish_button.dart b/lib/ride/views/finish_button.dart index 4b5ada78a..b297d7b93 100644 --- a/lib/ride/views/finish_button.dart +++ b/lib/ride/views/finish_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:priobike/common/layout/buttons.dart'; import 'package:priobike/common/layout/dialog.dart'; import 'package:priobike/common/layout/spacing.dart'; @@ -62,6 +63,11 @@ class FinishRideButtonState extends State { /// A callback that is executed when the cancel button is pressed. Future onTap() async { + // Allows only portrait mode again when leaving the ride view. + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]); + // End the tracking and collect the data. final tracking = getIt(); await tracking.end(); // Performs all needed resets. @@ -87,11 +93,8 @@ class FinishRideButtonState extends State { final position = getIt(); await position.stopGeolocation(); - // Show the feedback dialog. + // Show the feedback view. if (mounted) { - // Pop everything until ride view is reached and then replace it with the feedback view. - // Otherwise the ride view stays in the widget tree and is shown for a split seconds after closing the feedback view. - Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (BuildContext context) => FeedbackView( @@ -129,17 +132,24 @@ class FinishRideButtonState extends State { @override Widget build(BuildContext context) { + final orientation = MediaQuery.of(context).orientation; + final isLandscapeMode = orientation == Orientation.landscape; + return Stack( children: [ Positioned( top: 48, // Below the MapBox attribution. - right: 0, + // Button is on the right in portrait mode and on the left in landscape mode. + right: isLandscapeMode ? null : 0, + left: isLandscapeMode ? 0 : null, child: SafeArea( child: Tile( onPressed: () => showAskForConfirmationDialog(context), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(24), - bottomLeft: Radius.circular(24), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(24), + bottomLeft: const Radius.circular(24), + topRight: isLandscapeMode ? const Radius.circular(24) : const Radius.circular(0), + bottomRight: isLandscapeMode ? const Radius.circular(24) : const Radius.circular(0), ), padding: const EdgeInsets.all(4), fill: Colors.black.withOpacity(0.4), diff --git a/lib/ride/views/main.dart b/lib/ride/views/main.dart index 1297e0439..a1cceb225 100644 --- a/lib/ride/views/main.dart +++ b/lib/ride/views/main.dart @@ -1,5 +1,8 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:priobike/common/layout/buttons.dart'; import 'package:priobike/common/layout/ci.dart'; import 'package:priobike/common/lock.dart'; @@ -131,6 +134,14 @@ class RideViewState extends State { // Start tracking once the `sessionId` is set and the positioning stream is available. await tracking.start(deviceWidth, deviceHeight); + + // Allow user to rotate the screen in ride view. + // Landscape-Mode will be removed in FinishRideButton. + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + ]); }, ); } @@ -147,6 +158,7 @@ class RideViewState extends State { @override void dispose() { settings.removeListener(update); + super.dispose(); } @@ -155,74 +167,101 @@ class RideViewState extends State { // Keep the device active during navigation. Wakelock.enable(); - final displayHeight = MediaQuery.of(context).size.height; - final heightToPuck = displayHeight / 2; - final heightToPuckBoundingBox = heightToPuck - (displayHeight * 0.05); + final EdgeInsets paddingCenterButton; + final double heightToPuckBoundingBox; + final double positionSpeedometerRight; + + final orientation = MediaQuery.of(context).orientation; + + if (orientation == Orientation.portrait) { + // Portrait mode + final displayHeight = MediaQuery.of(context).size.height; + final heightToPuck = displayHeight / 2; + heightToPuckBoundingBox = heightToPuck - (displayHeight * 0.05); + paddingCenterButton = EdgeInsets.only( + bottom: heightToPuckBoundingBox < MediaQuery.of(context).size.width + ? heightToPuckBoundingBox - 35 + : MediaQuery.of(context).size.width - 35, + ); + positionSpeedometerRight = 0.0; + } else { + // Landscape mode + final displayWidth = MediaQuery.of(context).size.width; + final displayHeight = MediaQuery.of(context).size.height; + final heightToPuck = displayWidth / 2; + heightToPuckBoundingBox = heightToPuck - (displayWidth * 0.05); + paddingCenterButton = EdgeInsets.only(bottom: displayHeight * 0.15, right: displayWidth * 0.42); + positionSpeedometerRight = 6.0; + } return WillPopScope( onWillPop: () async => false, child: Scaffold( body: ScreenTrackingView( - child: Stack( - alignment: Alignment.bottomCenter, - clipBehavior: Clip.none, - children: [ - RideMapView( - onMapMoved: onMapMoved, - cameraFollowUserLocation: cameraFollowsUserLocation, - ), - if (settings.saveBatteryModeEnabled) - Positioned( - top: MediaQuery.of(context).size.height * 0.07, - left: 10, - child: const Image( - width: 100, - image: AssetImage('assets/images/mapbox-logo-transparent.png'), - ), + child: SafeArea( + top: false, + bottom: Platform.isAndroid ? true : false, + child: Stack( + alignment: Alignment.bottomCenter, + clipBehavior: Clip.none, + children: [ + RideMapView( + onMapMoved: onMapMoved, + cameraFollowUserLocation: cameraFollowsUserLocation, ), - if (settings.saveBatteryModeEnabled) - Positioned( - top: MediaQuery.of(context).size.height * 0.05, - right: 10, - child: IconButton( - onPressed: () => MapboxAttribution.showAttribution(context), - icon: const Icon( - Icons.info_outline_rounded, - size: 25, - color: CI.radkulturRed, + if (settings.saveBatteryModeEnabled) + Positioned( + top: MediaQuery.of(context).padding.top + 15, + left: 10, + child: const Image( + width: 100, + image: AssetImage('assets/images/mapbox-logo-transparent.png'), ), ), - ), - RideSpeedometerView(puckHeight: heightToPuckBoundingBox), - const DatastreamView(), - const FinishRideButton(), - if (!cameraFollowsUserLocation) - SafeArea( - bottom: true, - child: Padding( - padding: EdgeInsets.only( - bottom: heightToPuckBoundingBox < MediaQuery.of(context).size.width - ? heightToPuckBoundingBox - 35 - : MediaQuery.of(context).size.width - 35, - ), - child: BigButton( - icon: Icons.navigation_rounded, - iconColor: Colors.white, - fillColor: Theme.of(context).colorScheme.primary, - label: "Zentrieren", - elevation: 20, - onPressed: () { - final ride = getIt(); - if (ride.userSelectedSG != null) ride.unselectSG(); - setState(() { - cameraFollowsUserLocation = true; - }); - }, - boxConstraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width * 0.3, minHeight: 50), + if (settings.saveBatteryModeEnabled) + Positioned( + top: MediaQuery.of(context).padding.top + 5, + right: 10, + child: IconButton( + onPressed: () => MapboxAttribution.showAttribution(context), + icon: const Icon( + Icons.info_outline_rounded, + size: 25, + color: CI.radkulturRed, + ), ), ), + Positioned( + right: positionSpeedometerRight, + child: RideSpeedometerView(puckHeight: heightToPuckBoundingBox), ), - ], + const DatastreamView(), + const FinishRideButton(), + if (!cameraFollowsUserLocation) + SafeArea( + bottom: true, + child: Padding( + padding: paddingCenterButton, + child: BigButton( + icon: Icons.navigation_rounded, + iconColor: Colors.white, + fillColor: Theme.of(context).colorScheme.primary, + label: "Zentrieren", + elevation: 20, + onPressed: () { + final ride = getIt(); + if (ride.userSelectedSG != null) ride.unselectSG(); + setState(() { + cameraFollowsUserLocation = true; + }); + }, + boxConstraints: + BoxConstraints(minWidth: MediaQuery.of(context).size.width * 0.3, minHeight: 50), + ), + ), + ), + ], + ), ), ), ), diff --git a/lib/ride/views/map.dart b/lib/ride/views/map.dart index 143461c0c..e8b67432e 100644 --- a/lib/ride/views/map.dart +++ b/lib/ride/views/map.dart @@ -185,6 +185,32 @@ class RideMapViewState extends State { const vincenty = Distance(roundResult: false); + // Portrait/landscape mode + final orientation = MediaQuery.of(context).orientation; + final mapbox.MbxEdgeInsets padding; + if (orientation == Orientation.portrait) { + padding = mapbox.MbxEdgeInsets(top: 0, left: 0, bottom: 0, right: 0); + } else { + // Landscape-Mode: Set user-puk to the left and a little down + // The padding must be different if battery save mode is enabled by user because the map is rendered differently + final isBatterySaveModeEnabled = getIt().saveBatteryModeEnabled; + final deviceWidth = MediaQuery.of(context).size.width; + final deviceHeight = MediaQuery.of(context).size.height; + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + if (isBatterySaveModeEnabled) { + if (Platform.isAndroid) { + padding = mapbox.MbxEdgeInsets( + top: deviceHeight * 0.25, left: 0, bottom: 0, right: deviceWidth * pixelRatio * 0.18); + } else { + padding = + mapbox.MbxEdgeInsets(top: deviceHeight * 0.25, left: 0, bottom: 0, right: deviceWidth * pixelRatio * 0.4); + } + } else { + padding = + mapbox.MbxEdgeInsets(top: deviceHeight * 0.7, left: 0, bottom: 0, right: deviceWidth * pixelRatio * 0.4); + } + } + if (routing.hadErrorDuringFetch) { // If there was an error during fetching, we don't have a route and thus also can't snap the position. // We can only try to display the real user position. @@ -198,6 +224,7 @@ class RideMapViewState extends State { bearing: userPos.heading, zoom: 16, pitch: 60, + padding: padding, ), mapbox.MapAnimationOptions(duration: 1500)); await mapController?.style.styleLayerExists(userLocationLayerId).then((value) async { @@ -258,6 +285,7 @@ class RideMapViewState extends State { bearing: cameraHeading, zoom: zoom, pitch: 60, + padding: padding, ), mapbox.MapAnimationOptions(duration: 1500)); } @@ -435,13 +463,44 @@ class RideMapViewState extends State { if (!mounted) return; final isDark = Theme.of(context).brightness == Brightness.dark; await TrafficLightLayer(isDark).update(mapController!); - await mapController?.flyTo( - mapbox.CameraOptions( - center: mapbox.Point(coordinates: mapbox.Position(cameraTarget.longitude, cameraTarget.latitude)).toJson(), - padding: mapbox.MbxEdgeInsets(bottom: 200, top: 0, left: 0, right: 0), - ), - mapbox.MapAnimationOptions(duration: 200), - ); + + if (mounted) { + // Portrait/landscape mode + final orientation = MediaQuery.of(context).orientation; + final mapbox.MbxEdgeInsets padding; + + if (orientation == Orientation.portrait) { + padding = mapbox.MbxEdgeInsets(top: 0, left: 0, bottom: 200, right: 0); + } else { + // Landscape-Mode: Set user-puk to the left and a little down + // The padding must be different if battery save mode is enabled by user because the map is rendered differently + final isBatterySaveModeEnabled = getIt().saveBatteryModeEnabled; + final deviceWidth = MediaQuery.of(context).size.width; + final deviceHeight = MediaQuery.of(context).size.height; + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + if (isBatterySaveModeEnabled) { + if (Platform.isAndroid) { + padding = mapbox.MbxEdgeInsets( + top: deviceHeight * 0.45, left: 0, bottom: 200, right: deviceWidth * pixelRatio * 0.165); + } else { + padding = mapbox.MbxEdgeInsets( + top: deviceHeight * 0.45, left: 0, bottom: 200, right: deviceWidth * pixelRatio * 0.42); + } + } else { + padding = mapbox.MbxEdgeInsets( + top: deviceHeight * 0.45, left: 0, bottom: 200, right: deviceWidth * pixelRatio * 0.42); + } + } + + await mapController?.flyTo( + mapbox.CameraOptions( + center: + mapbox.Point(coordinates: mapbox.Position(cameraTarget.longitude, cameraTarget.latitude)).toJson(), + padding: padding, + ), + mapbox.MapAnimationOptions(duration: 200), + ); + } } } } @@ -453,7 +512,7 @@ class RideMapViewState extends State { // If this is fixed in an upcoming version of the Mapbox plugin, we may be able to remove those workaround adjustments // below. double marginYLogo = frame.padding.top; - double marginYAttribution = 0.0; + final double marginYAttribution; if (Platform.isAndroid) { final ppi = frame.devicePixelRatio; marginYLogo = marginYLogo * ppi; diff --git a/lib/ride/views/speedometer/shadow.dart b/lib/ride/views/speedometer/shadow.dart new file mode 100644 index 000000000..7c7f5ca4b --- /dev/null +++ b/lib/ride/views/speedometer/shadow.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +/// The radial shadow of the speedometer, used for landscape mode. +class SpeedometerRadialShadow extends StatelessWidget { + /// The size of the speedometer. + final Size size; + + const SpeedometerRadialShadow({Key? key, required this.size}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: size.width * 1.0, + height: size.width * 1.0, + decoration: BoxDecoration( + gradient: RadialGradient( + stops: Theme.of(context).colorScheme.brightness == Brightness.dark + ? const [0.7, 1.0] // Dark theme + : const [0.7, 1.0], // Light theme + + colors: Theme.of(context).colorScheme.brightness == Brightness.dark + ? [ + Colors.black.withOpacity(1), + Colors.black.withOpacity(0), + ] + : [ + Colors.black.withOpacity(0.5), + Colors.black.withOpacity(0.0), + ], + ), + ), + ); + } +} + +/// The linear shadow of the speedometer, used for portrait mode. +class SpeedometerLinearShadow extends StatelessWidget { + /// The height of the speedometer. + final double originalSpeedometerHeight; + + /// The width of the speedometer. + final double originalSpeedometerWidth; + + const SpeedometerLinearShadow({ + Key? key, + required this.originalSpeedometerHeight, + required this.originalSpeedometerWidth, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: originalSpeedometerHeight, + width: originalSpeedometerWidth, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: Theme.of(context).colorScheme.brightness == Brightness.dark + ? [ + Colors.black.withOpacity(0), + Colors.black.withOpacity(0.5), + Colors.black, + ] + : [ + Colors.black.withOpacity(0.0), + Colors.black.withOpacity(0.1), + Colors.black, + ], + stops: Theme.of(context).colorScheme.brightness == Brightness.dark + ? const [0.1, 0.3, 0.5] // Dark theme + : const [0.0, 0.1, 0.8], // Light theme + ), + ), + ); + } +} diff --git a/lib/ride/views/speedometer/view.dart b/lib/ride/views/speedometer/view.dart index c8682c1ff..de83ca562 100644 --- a/lib/ride/views/speedometer/view.dart +++ b/lib/ride/views/speedometer/view.dart @@ -16,6 +16,7 @@ import 'package:priobike/ride/views/speedometer/cover.dart'; import 'package:priobike/ride/views/speedometer/labels.dart'; import 'package:priobike/ride/views/speedometer/prediction_arc.dart'; import 'package:priobike/ride/views/speedometer/speed_arc.dart'; +import 'package:priobike/ride/views/speedometer/shadow.dart'; import 'package:priobike/ride/views/speedometer/ticks.dart'; import 'package:priobike/ride/views/trafficlight.dart'; import 'package:priobike/routing/services/routing.dart'; @@ -223,50 +224,48 @@ class RideSpeedometerViewState extends State with TickerPro Widget build(BuildContext context) { final speedkmh = minSpeed + (speedAnimationPct * (maxSpeed - minSpeed)); - final originalSpeedometerHeight = MediaQuery.of(context).size.width; - final originalSpeedometerWidth = MediaQuery.of(context).size.width; - final remainingDistance = (((ride.route?.path.distance ?? 0.0) - (positioning.snap?.distanceOnRoute ?? 0.0)) / 1000).abs(); final remainingMinutes = remainingDistance / (18 / 60); final timeOfArrival = DateTime.now().add(Duration(minutes: remainingMinutes.toInt())); - final size = Size(originalSpeedometerWidth, originalSpeedometerHeight); - final showAlert = routing.hadErrorDuringFetch; + final orientation = MediaQuery.of(context).orientation; + final isLandscapeMode = orientation == Orientation.landscape; + final double originalSpeedometerHeight; + final double originalSpeedometerWidth; + final double sizedBoxHeight; + final double? sizedBoxWidth; + + if (orientation == Orientation.portrait) { + // Portrait mode + originalSpeedometerHeight = MediaQuery.of(context).size.width; + originalSpeedometerWidth = MediaQuery.of(context).size.width; + sizedBoxHeight = widget.puckHeight; + sizedBoxWidth = null; + } else { + // Landscape mode + originalSpeedometerHeight = MediaQuery.of(context).size.height; + originalSpeedometerWidth = MediaQuery.of(context).size.height; + sizedBoxHeight = originalSpeedometerHeight; + sizedBoxWidth = originalSpeedometerWidth; + } + final size = Size(originalSpeedometerWidth, originalSpeedometerHeight); + return Stack( alignment: Alignment.bottomCenter, children: [ - Container( - height: originalSpeedometerHeight, - width: originalSpeedometerWidth, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: Theme.of(context).colorScheme.brightness == Brightness.dark - ? [ - Colors.black.withOpacity(0), - Colors.black.withOpacity(0.5), - Colors.black, - ] - : [ - Colors.black.withOpacity(0.0), - Colors.black.withOpacity(0.1), - Colors.black, - ], - stops: Theme.of(context).colorScheme.brightness == Brightness.dark - ? const [0.1, 0.3, 0.5] // Dark theme - : const [0.0, 0.1, 0.8], // Light theme - ), - ), - ), + isLandscapeMode + ? Container() + : SpeedometerLinearShadow( + originalSpeedometerHeight: originalSpeedometerHeight, + originalSpeedometerWidth: originalSpeedometerWidth), SafeArea( bottom: true, child: SizedBox( - height: widget.puckHeight, + height: sizedBoxHeight, + width: sizedBoxWidth, child: FittedBox( fit: BoxFit.contain, child: SizedBox( @@ -275,6 +274,13 @@ class RideSpeedometerViewState extends State with TickerPro child: Stack( alignment: Alignment.bottomCenter, children: [ + if (isLandscapeMode) + Transform.translate( + offset: const Offset(0, 42), + child: Center( + child: SpeedometerRadialShadow(size: size), + ), + ), if (showAlert) Transform.translate( offset: const Offset(0, 42), @@ -289,8 +295,15 @@ class RideSpeedometerViewState extends State with TickerPro // When the user taps on the speedometer, we want to set the speed to the tapped speed. onTapUp: (details) { // Get the center of the speedometer - final xRel = details.localPosition.dx / MediaQuery.of(context).size.width; - final yRel = details.localPosition.dy / MediaQuery.of(context).size.width; + final double xRel; + final double yRel; + if (isLandscapeMode) { + xRel = details.localPosition.dx / MediaQuery.of(context).size.height; + yRel = details.localPosition.dy / MediaQuery.of(context).size.height; + } else { + xRel = details.localPosition.dx / MediaQuery.of(context).size.width; + yRel = details.localPosition.dy / MediaQuery.of(context).size.width; + } // Transform the angle of the tapped position into an intuitive angle system: // 0 deg is south, 90 deg is west, 180 deg is north, 270 deg is east. final angleDeg = atan2(yRel - 0.5, xRel - 0.5) * 180 / pi - 90; @@ -304,7 +317,7 @@ class RideSpeedometerViewState extends State with TickerPro child: Stack( fit: StackFit.expand, children: [ - CustomPaint(painter: SpeedometerCoverPainter()), + if (!isLandscapeMode) CustomPaint(painter: SpeedometerCoverPainter()), CustomPaint( size: size, painter: SpeedometerBackgroundPainter(