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(