diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 9056973..26c43c6 --- a/README.md +++ b/README.md @@ -1,16 +1,80 @@ -# lg_space_visualizations +# πŸš€ Space Visualizations for Liquid Galaxy -This is an educational application dedicated to visualizing orbits and the Mars 2020 mission, utilizing the Liquid Galaxy platform, to provide immersive space exploration experiences. +## πŸ“„Table of Contents +* [πŸ“š About](#-about) +* [πŸ“ Requirements](#-requirements) +* [πŸ‘¨β€πŸ’» Building from source](#-building-from-source) +* [🌐 Connecting to Liquid Galaxy](#-connecting-to-LG) -## Getting Started + +## πŸ“š About -This project is a starting point for a Flutter application. +Space Visualizations for Liquid Galaxy is an application that showcases the [Mars 2020](https://science.nasa.gov/mission/mars-2020-perseverance/) NASA mission and some of the most famous Earth orbits. The application uses the [Liquid Galaxy](https://www.liquidgalaxy.eu) platform to provide immersive space exploration experiences. In the Mars mission section, users can interactively learn about the mission by visualizing 3D models, technical data, and the path of the Perseverance rover and Ingenuity drone. Users can see Mars from the perspective of the Perseverance rover with more than 220000 photos available. The photos can also be displayed on all Liquid Galaxy screens, providing a very immersive experience. -A few resources to get you started if this is your first Flutter project: +In the Earth orbit section, a list of orbits can be displayed in both the application and, with a realistic representation, on Liquid Galaxy Google Earth. Users can interact with these orbits and learn more about them. -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +This project has been started as a [Google Summer of Code](https://summerofcode.withgoogle.com/about) 2024 project with the Liquid Galaxy Org. +Developed by Mattia Baggini +Mentor: Victor Sanchez +Liquid Galaxy Org Director: Andreu IbaΓ±ez -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. + +## πŸ“ Requirements + +1. **Device Compatibility:** + - The application requires an Android tablet running **Android 13 (API level 33)** or higher. + +2. **Liquid Galaxy Integration (Optional):** + - To fully utilize the Liquid Galaxy features, ensure that the **Liquid Galaxy core** is installed. For detailed installation instructions, please refer to the [Liquid Galaxy repository](https://github.com/LiquidGalaxyLAB/liquid-galaxy). + +3. **Displaying Rover Photos on Liquid Galaxy (Optional):** + - To enable the display of rover photos on the Liquid Galaxy screens, install the **display_images_service**. For more information and installation guidelines, visit the [display_images_service repository](https://github.com/0xbaggi/display_images_service). + + + +## πŸ‘¨β€πŸ’» Building from source + +First, open a new terminal and clone the repository with the command: + +```bash +git clone https://github.com/LiquidGalaxyLAB/LG-Space-Visualizations.git +``` + +To use the Google Maps widget, you'll need to set up an API key for the [Google Maps SDK](https://developers.google.com/maps/documentation/android-sdk/overview). Follow these steps: + +1. Obtain a Google Maps API Key by following the instructions [here](https://developers.google.com/maps/documentation/android-sdk/get-api-key). +2. Once you have the API key, navigate to the `android/app/main` directory within the cloned repository. +3. Open the **AndroidManifest.xml** file in a text editor. +4. Locate the following section in the **AndroidManifest.xml** file: + +```XML + +``` + +Now we can run the application, follow these steps: + +1. Navigate to the project directory: + ```bash + cd LG-Space-Visualizations + ``` +2. Install the necessary dependencies: + ```bash + flutter pub get + ``` +3. Launch the app: + ```bash + flutter run + ``` +> ❗ **Important:** Ensure you have a tablet device connected or an Android tablet emulator running before executing the `flutter run` command. + + +## 🌐 Connecting to Liquid Galaxy + +1. **Open the Application**: Launch the application on your device and go to the settings page. +![usage1](assets/readme_images/usage1.png) +2. **Enter Liquid Galaxy Details**: Insert your Liquid Galaxy information into the form and click "Connect" +![usage2](assets/readme_images/usage2.png) +3. **Confirmation**: If a confirmation message appears, your application is successfully connected! +![usage3](assets/readme_images/usage3.png) \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index ffb4165..6c5c741 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -53,7 +53,7 @@ android { minSdkVersion 33 targetSdkVersion 34 versionCode 6 - versionName '1.6.0' + versionName '2.0.0' } signingConfigs { diff --git a/assets/readme_images/usage1.png b/assets/readme_images/usage1.png new file mode 100755 index 0000000..4b57126 Binary files /dev/null and b/assets/readme_images/usage1.png differ diff --git a/assets/readme_images/usage2.png b/assets/readme_images/usage2.png new file mode 100755 index 0000000..ac4215d Binary files /dev/null and b/assets/readme_images/usage2.png differ diff --git a/assets/readme_images/usage3.png b/assets/readme_images/usage3.png new file mode 100755 index 0000000..2a00b73 Binary files /dev/null and b/assets/readme_images/usage3.png differ diff --git a/lib/main.dart b/lib/main.dart index 85e7e77..c1a7ab5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lg_space_visualizations/utils/routes.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; void main() { // Ensures that an instance of the widgets library is initialized. @@ -34,23 +35,25 @@ class Launcher extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - // Set a background image for the entire app. - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage("assets/images/background.jpg"), - fit: BoxFit.fill, - ), - ), - child: MaterialApp( - title: 'SpaceVisualizations', - // Set the app theme with a custom font. - theme: ThemeData(fontFamily: 'Forgotten Futurist'), - // Define the route generator function. - onGenerateRoute: makeRoute, - // Set the initial route to the splash screen. - initialRoute: '/splash', - ), - ); + return ShowCaseWidget( + builder: (context) => Container( + // Set a background image for the entire app. + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage("assets/images/background.jpg"), + fit: BoxFit.fill, + ), + ), + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'SpaceVisualizations', + // Set the app theme with a custom font. + theme: ThemeData(fontFamily: 'Forgotten Futurist'), + // Define the route generator function. + onGenerateRoute: makeRoute, + // Set the initial route to the splash screen. + initialRoute: '/splash', + ), + )); } } diff --git a/lib/pages/cameras_filters_page.dart b/lib/pages/cameras_filters_page.dart index 62be026..3b1cccd 100644 --- a/lib/pages/cameras_filters_page.dart +++ b/lib/pages/cameras_filters_page.dart @@ -8,6 +8,8 @@ import 'package:lg_space_visualizations/widget/button.dart'; import 'package:lg_space_visualizations/widget/custom_dialog.dart'; import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/pop_up.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// A page that allows users to filter camera images by date and number of photos. /// @@ -43,6 +45,12 @@ class _CamerasFiltersPageState extends State { // Custom thumb shape for the range slider late IndicatorRangeSliderThumbShape indicatorRangeSliderThumbShape; + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + final GlobalKey _threeShowCase = GlobalKey(); + final GlobalKey _fourShowCase = GlobalKey(); + @override void initState() { super.initState(); @@ -55,6 +63,17 @@ class _CamerasFiltersPageState extends State { indicatorRangeSliderThumbShape = IndicatorRangeSliderThumbShape( widget.filter.rangePhotosValuesStart, widget.filter.rangePhotosValuesEnd); + + // Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseCamerasFilterPage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context).startShowCase( + [_oneShowCase, _twoShowCase, _threeShowCase, _fourShowCase]); + prefs.setBool('showcaseCamerasFilterPage', false); + }); + } + }); } /// Displays a date picker dialog to select a date. @@ -152,19 +171,24 @@ class _CamerasFiltersPageState extends State { left: spaceBetweenWidgets, right: spaceBetweenWidgets, bottom: spaceBetweenWidgets / 2), - child: Row( - children: [ - CustomIcon( - name: 'startdate', size: 40, color: secondaryColor), - SizedBox(width: spaceBetweenWidgets), - _buildDateSelector('start', true, _selectedStartDate), - SizedBox(width: 4 * spaceBetweenWidgets), - CustomIcon( - name: 'enddate', size: 40, color: secondaryColor), - SizedBox(width: spaceBetweenWidgets), - _buildDateSelector('end', false, _selectedEndDate), - ], - )), + child: Showcase( + key: _oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseCamerasFiltersTitle, + description: oneShowcaseCamerasFiltersDescription, + child: Row( + children: [ + CustomIcon( + name: 'startdate', size: 40, color: secondaryColor), + SizedBox(width: spaceBetweenWidgets), + _buildDateSelector('start', true, _selectedStartDate), + SizedBox(width: 4 * spaceBetweenWidgets), + CustomIcon( + name: 'enddate', size: 40, color: secondaryColor), + SizedBox(width: spaceBetweenWidgets), + _buildDateSelector('end', false, _selectedEndDate), + ], + ))), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(filterPhotoNumberText, style: middleTitle), Button( @@ -191,28 +215,34 @@ class _CamerasFiltersPageState extends State { offset: const Offset(0, -10), child: Divider(color: grey, thickness: 1)), SizedBox(height: spaceBetweenWidgets / 4), - SliderTheme( - data: SliderThemeData( - showValueIndicator: ShowValueIndicator.never, - rangeThumbShape: indicatorRangeSliderThumbShape, - ), - child: RangeSlider( - values: _currentRangeValues, - onChanged: (values) { - indicatorRangeSliderThumbShape.start = values.start.toInt(); - indicatorRangeSliderThumbShape.end = values.end.toInt(); - setState(() => _currentRangeValues = values); - }, - activeColor: secondaryColor, - inactiveColor: secondaryColor.withOpacity(0.5), - max: divisions.toDouble(), - min: 1, - divisions: divisions, - labels: RangeLabels( - _currentRangeValues.start.round().toString(), - _currentRangeValues.end.round().toString(), - ), - )), + Showcase( + key: _twoShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseCamerasFiltersTitle, + description: twoShowcaseCamerasFiltersDescription, + child: SliderTheme( + data: SliderThemeData( + showValueIndicator: ShowValueIndicator.never, + rangeThumbShape: indicatorRangeSliderThumbShape, + ), + child: RangeSlider( + values: _currentRangeValues, + onChanged: (values) { + indicatorRangeSliderThumbShape.start = + values.start.toInt(); + indicatorRangeSliderThumbShape.end = values.end.toInt(); + setState(() => _currentRangeValues = values); + }, + activeColor: secondaryColor, + inactiveColor: secondaryColor.withOpacity(0.5), + max: divisions.toDouble(), + min: 1, + divisions: divisions, + labels: RangeLabels( + _currentRangeValues.start.round().toString(), + _currentRangeValues.end.round().toString(), + ), + ))), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -243,28 +273,38 @@ class _CamerasFiltersPageState extends State { Transform.translate( offset: const Offset(0, -10), child: Divider(color: grey, thickness: 1)), - _buildCameraGrid(), + Showcase( + key: _threeShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: threeShowcaseCamerasFiltersTitle, + description: threeShowcaseCamerasFiltersDescription, + child: _buildCameraGrid()), const Spacer(), - Button( - color: secondaryColor, - padding: EdgeInsets.only( - top: spaceBetweenWidgets / 4, - bottom: spaceBetweenWidgets / 4), - text: showResultButtonText, - borderRadius: BorderRadius.circular(borderRadius), - icon: - CustomIcon(name: 'search', size: 40, color: backgroundColor), - onPressed: () { - Filter.storeFilter( - _currentRangeValues.start.round(), - _currentRangeValues.end.round(), - _selectedStartDate, - _selectedEndDate, - _selectedCameras); - Navigator.removeRoute(context, ModalRoute.of(context)!); - Navigator.pushReplacementNamed(context, '/cameras'); - }, - ) + Showcase( + key: _fourShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: fourShowcaseCamerasFiltersTitle, + description: fourShowcaseCamerasFiltersDescription, + child: Button( + color: secondaryColor, + padding: EdgeInsets.only( + top: spaceBetweenWidgets / 4, + bottom: spaceBetweenWidgets / 4), + text: showResultButtonText, + borderRadius: BorderRadius.circular(borderRadius), + icon: CustomIcon( + name: 'search', size: 40, color: backgroundColor), + onPressed: () { + Filter.storeFilter( + _currentRangeValues.start.round(), + _currentRangeValues.end.round(), + _selectedStartDate, + _selectedEndDate, + _selectedCameras); + Navigator.removeRoute(context, ModalRoute.of(context)!); + Navigator.pushReplacementNamed(context, '/cameras'); + }, + )) ], ), ), diff --git a/lib/pages/cameras_page.dart b/lib/pages/cameras_page.dart index c351a9d..ce2b910 100644 --- a/lib/pages/cameras_page.dart +++ b/lib/pages/cameras_page.dart @@ -11,6 +11,8 @@ import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/days_list.dart'; import 'package:lg_space_visualizations/widget/loading_indicator.dart'; import 'package:lg_space_visualizations/widget/pop_up.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// Widget for displaying cameras page class CamerasPage extends StatefulWidget { @@ -30,8 +32,33 @@ class _CamerasPageState extends State { /// Variable to store total number of photos late int totalPhotos; + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + final GlobalKey _threeShowCase = GlobalKey(); + final GlobalKey _fourShowCase = GlobalKey(); + + /// Method to show the showcase tutorial + void showCase() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + if (prefs.getBool('showcaseCamerasPage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context).startShowCase([ + _oneShowCase, + oneDaysListShowcase!, + _twoShowCase, + _threeShowCase, + _fourShowCase, + ]); + prefs.setBool('showcaseCamerasPage', false); + }); + } + } + /// Method to load data from NASA API using manifest endpoint Future loadData() async { + + // Fetch manifest data from NASA API Map data = await NasaApi.loadManifestData(); @@ -54,6 +81,9 @@ class _CamerasPageState extends State { // Load filter with the maximum number of photos taken in a day and the latest Earth date available. This will calculate the filter range. filter = await Filter.loadFilter( SolDay.getMaxPhotos(allDays), allDays.last.earthDate); + + // Show the showcase tutorial if it's the first time the user opens the page + showCase(); } @override @@ -82,8 +112,15 @@ class _CamerasPageState extends State { buildHeaderRow(), SizedBox(height: spaceBetweenWidgets / 2), Expanded( - child: - DaysList(allDays: allDays, filter: filter)) + child: Showcase( + key: _oneShowCase, + targetBorderRadius: + BorderRadius.circular(borderRadius), + title: oneShowcaseCamerasPageTitle, + description: + oneShowcaseCamerasPageDescription, + child: DaysList( + allDays: allDays, filter: filter))) ], ); } else if (snapshot.hasError) { @@ -115,61 +152,76 @@ class _CamerasPageState extends State { ), Tooltip( message: tooltipRefreshText, - child: Button( - color: secondaryColor, - borderRadius: BorderRadius.circular(50), - icon: CustomIcon( - name: 'refresh', color: backgroundColor, size: 45), - onPressed: () { - setState(() { - // Update list from NASA API clearing cache - NasaApi.clearCache(); - }); - })), + child: Showcase( + key: _twoShowCase, + targetShapeBorder: const CircleBorder(), + title: twoShowcaseCamerasPageTitle, + description: twoShowcaseCamerasPageDescription, + child: Button( + color: secondaryColor, + borderRadius: BorderRadius.circular(50), + icon: CustomIcon( + name: 'refresh', color: backgroundColor, size: 45), + onPressed: () { + setState(() { + // Update list from NASA API clearing cache + NasaApi.clearCache(); + }); + }))), SizedBox(width: spaceBetweenWidgets), Tooltip( message: tooltipCameraPositionText, - child: Button( - color: secondaryColor, - borderRadius: BorderRadius.circular(50), - icon: - CustomIcon(name: 'info', color: backgroundColor, size: 45), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return PopUp( - child: Container( - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(borderRadius), - color: backgroundColor, - ), - child: Image.asset( - 'assets/images/rover_cameras.png'))); - }, - ); - })), + child: Showcase( + key: _threeShowCase, + targetShapeBorder: const CircleBorder(), + title: threeShowcaseCamerasPageTitle, + description: threeShowcaseCamerasPageDescription, + child: Button( + color: secondaryColor, + borderRadius: BorderRadius.circular(50), + icon: CustomIcon( + name: 'info', color: backgroundColor, size: 45), + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return PopUp( + child: Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(borderRadius), + color: backgroundColor, + ), + child: Image.asset( + 'assets/images/rover_cameras.png'))); + }, + ); + }))), SizedBox(width: spaceBetweenWidgets), Tooltip( message: tooltipFilterText, - child: Button( - color: secondaryColor, - borderRadius: BorderRadius.circular(50), - icon: CustomIcon( - name: 'filter', color: backgroundColor, size: 45), - onPressed: () async { - Filter filter = await Filter.loadFilter( - SolDay.getMaxPhotos(allDays), allDays.last.earthDate); - showDialog( - context: context, - builder: (BuildContext context) { - return PopUp( - child: CamerasFiltersPage( - days: allDays, filter: filter)); - }, - ); - })) + child: Showcase( + key: _fourShowCase, + targetShapeBorder: const CircleBorder(), + title: fourShowcaseCamerasPageTitle, + description: fourShowcaseCamerasPageDescription, + child: Button( + color: secondaryColor, + borderRadius: BorderRadius.circular(50), + icon: CustomIcon( + name: 'filter', color: backgroundColor, size: 45), + onPressed: () async { + Filter filter = await Filter.loadFilter( + SolDay.getMaxPhotos(allDays), allDays.last.earthDate); + showDialog( + context: context, + builder: (BuildContext context) { + return PopUp( + child: CamerasFiltersPage( + days: allDays, filter: filter)); + }, + ); + }))) ], ); } diff --git a/lib/pages/drone_page.dart b/lib/pages/drone_page.dart index 62baf67..c58f93d 100644 --- a/lib/pages/drone_page.dart +++ b/lib/pages/drone_page.dart @@ -11,6 +11,8 @@ import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/info_box.dart'; import 'package:lg_space_visualizations/widget/map.dart'; import 'package:lg_space_visualizations/widget/view_model.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// [DronePage] is a widget that displays information about the Ingenuity Drone. /// @@ -23,10 +25,34 @@ class DronePage extends StatefulWidget { } class _DronePageState extends State { + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + final GlobalKey _threeShowCase = GlobalKey(); + final GlobalKey _fourShowCase = GlobalKey(); + final GlobalKey _fiveShowCase = GlobalKey(); + @override void initState() { super.initState(); showVisualizations(); + + /// Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseDronePage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context).startShowCase([ + _oneShowCase, + _twoShowCase, + _threeShowCase, + oneBottomBarMapShowcase!, + _fourShowCase, + _fiveShowCase + ]); + prefs.setBool('showcaseDronePage', false); + }); + } + }); } @override @@ -64,44 +90,62 @@ class _DronePageState extends State { padding: EdgeInsets.all(spaceBetweenWidgets / 2), child: Column( children: [ - const ViewModel( - model: 'assets/models/ingenuity_drone.glb', - backgroundImage: 'assets/images/model_background.png', - alt: 'ingenuity drone', - cameraOrbit: '-10deg 78deg 2m', - ), + Expanded( + child: Showcase( + key: _twoShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseDronePageTitle, + description: twoShowcaseDronePageDescription, + child: const ViewModel( + model: 'assets/models/ingenuity_drone.glb', + backgroundImage: 'assets/images/model_background.png', + alt: 'ingenuity drone', + cameraOrbit: '-10deg 78deg 2m', + ))), SizedBox(height: spaceBetweenWidgets - 5), - Button( - color: secondaryColor, - center: false, - text: droneLearnMoreText, - padding: const EdgeInsets.only(left: 15), - borderRadius: BorderRadius.circular(borderRadius), - icon: CustomIcon( - name: 'read', size: 50, color: backgroundColor), - onPressed: () { - setState(() { - setState(() { - Navigator.pushNamed(context, '/web', - arguments: {'url': droneUrl, 'title': droneTitle}); - }); - }); - }, - ), + Showcase( + key: _fourShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: fourShowcaseDronePageTitle, + description: fourShowcaseDronePageDescription, + child: Button( + color: secondaryColor, + center: false, + text: droneLearnMoreText, + padding: const EdgeInsets.only(left: 15), + borderRadius: BorderRadius.circular(borderRadius), + icon: CustomIcon( + name: 'read', size: 50, color: backgroundColor), + onPressed: () { + setState(() { + setState(() { + Navigator.pushNamed(context, '/web', arguments: { + 'url': droneUrl, + 'title': droneTitle + }); + }); + }); + }, + )), SizedBox(height: spaceBetweenWidgets / 2), - Button( - color: secondaryColor, - center: false, - text: meetTheRoverText, - padding: const EdgeInsets.only(left: 15), - borderRadius: BorderRadius.circular(borderRadius), - icon: CustomIcon( - name: 'rover', size: 50, color: backgroundColor), - onPressed: () { - setState(() { - Navigator.pushNamed(context, '/rover'); - }); - }), + Showcase( + key: _fiveShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: fiveShowcaseDronePageTitle, + description: fiveShowcaseDronePageDescription, + child: Button( + color: secondaryColor, + center: false, + text: meetTheRoverText, + padding: const EdgeInsets.only(left: 15), + borderRadius: BorderRadius.circular(borderRadius), + icon: CustomIcon( + name: 'rover', size: 50, color: backgroundColor), + onPressed: () { + setState(() { + Navigator.pushNamed(context, '/rover'); + }); + })), ], ), ), @@ -124,53 +168,63 @@ class _DronePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - titleDroneDescriptionText, - style: middleTitle, - textAlign: TextAlign.left, - ), - Text(droneIntroText, style: smallText), - SizedBox(height: spaceBetweenWidgets / 4), - Text(droneDescriptionText, style: smallText), + Showcase( + key: _oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseDronePageTitle, + description: oneShowcaseDronePageDescription, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titleDroneDescriptionText, + style: middleTitle, + textAlign: TextAlign.left, + ), + Text(droneIntroText, style: smallText), + SizedBox(height: spaceBetweenWidgets / 4), + Text(droneDescriptionText, style: smallText), + ])), SizedBox(height: spaceBetweenWidgets / 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InfoBox( - text: droneFirstDataValue, - subText: droneFirstDataText), + text: droneFirstDataValue, subText: droneFirstDataText), InfoBox( text: droneSecondDataValue, subText: droneSecondDataText), InfoBox( - text: droneThirdDataValue, - subText: droneThirdDataText), + text: droneThirdDataValue, subText: droneThirdDataText), InfoBox( text: droneFourthDataValue, subText: droneFourthDataText), InfoBox( - text: droneFifthDataValue, - subText: droneFifthDataText), + text: droneFifthDataValue, subText: droneFifthDataText), InfoBox( - text: droneSixthDataValue, - subText: droneSixthDataText), + text: droneSixthDataValue, subText: droneSixthDataText), ], ), SizedBox(height: spaceBetweenWidgets / 4), Expanded( - child: Map( - latitude: mapMarsCenterLat, - longitude: mapMarsCenterLong, - zoom: defaultMarsMapZoom, - tilt: defaultMarsMapTilt, - bearing: defaultMarsMapBearing, - minMaxZoomPreference: - const MinMaxZoomPreference(11, 14), - bounds: roverLandingBounds, - boost: defaultMarsMapBoost, - orbitTilt: defaultMarsOrbitTilt, - orbitRange: defaultMarsOrbitRange, - kmlName: 'Drone')), + child: Showcase( + key: _threeShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: threeShowcaseDronePageTitle, + description: threeShowcaseDronePageDescription, + child: Map( + latitude: mapMarsCenterLat, + longitude: mapMarsCenterLong, + zoom: defaultMarsMapZoom, + tilt: defaultMarsMapTilt, + bearing: defaultMarsMapBearing, + minMaxZoomPreference: + const MinMaxZoomPreference(11, 14), + bounds: roverLandingBounds, + boost: defaultMarsMapBoost, + orbitTilt: defaultMarsOrbitTilt, + orbitRange: defaultMarsOrbitRange, + kmlName: 'Drone'))), ], ), ), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index d94aec5..72ff571 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,11 +1,15 @@ + import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:lg_space_visualizations/pages/template_page.dart'; import 'package:lg_space_visualizations/utils/lg_connection.dart'; import 'package:lg_space_visualizations/utils/text_constants.dart'; +import 'package:lg_space_visualizations/widget/bottom_bar.dart'; import 'package:lg_space_visualizations/widget/image_button.dart'; import 'package:lg_space_visualizations/utils/styles.dart'; import 'package:lg_space_visualizations/widget/logo.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// The home page of the application, displayed using the [TemplatePage] widget. class HomePage extends StatefulWidget { @@ -16,12 +20,34 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { + /// The showcase keys + final GlobalKey oneShowCase = GlobalKey(); + final GlobalKey twoShowCase = GlobalKey(); @override void initState() { // Clear the KML when the page is disposed, keeping the logos lgConnection.clearKml(keepLogos: true); + super.initState(); + + // Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseHomePage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context).startShowCase([ + oneBottomBar!, + twoBottomBar!, + threeBottomBar!, + fourBottomBar!, + fiveBottomBar!, + oneShowCase, + twoShowCase, + ]); + prefs.setBool('showcaseHomePage', false); + }); + } + }); } @override @@ -39,31 +65,41 @@ class _HomePageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - ImageButton( - width: 425, - height: 220, - image: const AssetImage('assets/images/rover.png'), - text: marsSectionTitle, - onPressed: () { - setState(() { - Navigator.pushNamed(context, '/mars'); - }); - }, - ), - ImageButton( - width: 425, - height: 220, - image: const AssetImage('assets/images/earth.png'), - text: earthSectionTitle, - onPressed: () { - setState(() { - Navigator.pushNamed( - context, - '/orbits', - ); - }); - }, - ), + Showcase( + key: oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseHomePageTitle, + description: oneShowcaseHomePageDescription, + child: ImageButton( + width: 425, + height: 220, + image: const AssetImage('assets/images/rover.png'), + text: marsSectionTitle, + onPressed: () { + setState(() { + Navigator.pushNamed(context, '/mars'); + }); + }, + )), + Showcase( + key: twoShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseHomePageTitle, + description: twoShowcaseHomePageDescription, + child: ImageButton( + width: 425, + height: 220, + image: const AssetImage('assets/images/earth.png'), + text: earthSectionTitle, + onPressed: () { + setState(() { + Navigator.pushNamed( + context, + '/orbits', + ); + }); + }, + )), ], ), ) diff --git a/lib/pages/info_page.dart b/lib/pages/info_page.dart index ec53d93..f11b9a7 100644 --- a/lib/pages/info_page.dart +++ b/lib/pages/info_page.dart @@ -90,6 +90,11 @@ class _InfoPageState extends State { infoPageDescription, style: middleText, ), + const Spacer(), + Text( + infoPageFooter, + style: middleText, + ), ], ), ), diff --git a/lib/pages/orbit_page.dart b/lib/pages/orbit_page.dart index 434914a..440a3b4 100644 --- a/lib/pages/orbit_page.dart +++ b/lib/pages/orbit_page.dart @@ -9,6 +9,8 @@ import 'package:lg_space_visualizations/utils/orbit.dart'; import 'package:lg_space_visualizations/utils/styles.dart'; import 'package:lg_space_visualizations/utils/text_constants.dart'; import 'package:lg_space_visualizations/widget/map.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// A page that displays detailed information and visualizations for a specific [Orbit]. class OrbitPage extends StatefulWidget { @@ -28,11 +30,28 @@ class _OrbitPageState extends State { /// Set of polylines to display the orbit path on the map. final Set polylines = {}; + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + @override void initState() { super.initState(); loadPolylines(); showVisualizations(); + + // Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseOrbitPage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context).startShowCase([ + _oneShowCase, + _twoShowCase, + ]); + prefs.setBool('showcaseOrbitPage', false); + }); + } + }); } @override @@ -77,90 +96,100 @@ class _OrbitPageState extends State { children: [ Expanded( flex: 3, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: backgroundColor, - ), - child: Padding( - padding: EdgeInsets.only( - left: spaceBetweenWidgets, - right: spaceBetweenWidgets, - top: spaceBetweenWidgets / 2, - bottom: spaceBetweenWidgets, + child: Showcase( + key: _oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseOrbitPageTitle, + description: + '$oneShowcaseOrbitPageDescription1 ${widget.orbit.orbitName} $oneShowcaseOrbitPageDescription2 ${widget.orbit.satelliteName ?? oneShowcaseOrbitPageDescription3} $oneShowcaseOrbitPageDescription4', + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + color: backgroundColor, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '$orbitDescriptionTitle ${widget.orbit.orbitName}', - style: middleTitle, - textAlign: TextAlign.left, - ), - SizedBox(height: spaceBetweenWidgets / 2), - - Text( - widget.orbit.orbitDescription, - style: smallText, + child: Padding( + padding: EdgeInsets.only( + left: spaceBetweenWidgets, + right: spaceBetweenWidgets, + top: spaceBetweenWidgets / 2, + bottom: spaceBetweenWidgets, ), - SizedBox(height: spaceBetweenWidgets / 2), - - // Conditionally displays information about the satellite if available - if (widget.orbit.satelliteDescription != null) ...[ - Text( - '$orbitDescriptionTitle ${widget.orbit - .satelliteName} $orbitSatellite', - style: middleTitle, - textAlign: TextAlign.left, - ), - SizedBox(height: spaceBetweenWidgets / 2), - Text( - widget.orbit.satelliteDescription!, - style: smallText, - ), - ], - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$orbitDescriptionTitle ${widget.orbit.orbitName}', + style: middleTitle, + textAlign: TextAlign.left, + ), + SizedBox(height: spaceBetweenWidgets / 2), + + Text( + widget.orbit.orbitDescription, + style: smallText, + ), + SizedBox(height: spaceBetweenWidgets / 2), + + // Conditionally displays information about the satellite if available + if (widget.orbit.satelliteDescription != null) ...[ + Text( + '$orbitDescriptionTitle ${widget.orbit.satelliteName} $orbitSatellite', + style: middleTitle, + textAlign: TextAlign.left, + ), + SizedBox(height: spaceBetweenWidgets / 2), + Text( + widget.orbit.satelliteDescription!, + style: smallText, + ), + ], + ], + )), ), ), ), SizedBox(width: spaceBetweenWidgets), Expanded( flex: 7, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: backgroundColor, - ), - child: Padding( - padding: EdgeInsets.only( - left: spaceBetweenWidgets / 2, - right: spaceBetweenWidgets / 2, - top: spaceBetweenWidgets / 2, - bottom: spaceBetweenWidgets / 2, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Map( - latitude: widget.orbit.centerLatitude, - longitude: widget.orbit.centerLongitude, - zoom: defaultEarthMapZoom, - tilt: defaultEarthMapTilt, - bearing: defaultEarthMapBearing, - mapType: MapType.satellite, - boost: defaultEarthMapBoost, - polylines: polylines, - orbitTilt: defaultEarthOrbitTilt, - orbitRange: defaultEarthOrbitRange, - canOrbit: false, - ), + child: Showcase( + key: _twoShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseOrbitPageTitle, + description: twoShowcaseOrbitPageDescription, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + color: backgroundColor, + ), + child: Padding( + padding: EdgeInsets.only( + left: spaceBetweenWidgets / 2, + right: spaceBetweenWidgets / 2, + top: spaceBetweenWidgets / 2, + bottom: spaceBetweenWidgets / 2, ), - ], - ), - ), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Map( + latitude: widget.orbit.centerLatitude, + longitude: widget.orbit.centerLongitude, + zoom: defaultEarthMapZoom, + tilt: defaultEarthMapTilt, + bearing: defaultEarthMapBearing, + mapType: MapType.satellite, + boost: defaultEarthMapBoost, + polylines: polylines, + orbitTilt: defaultEarthOrbitTilt, + orbitRange: defaultEarthOrbitRange, + canOrbit: false, + ), + ), + ], + ), + ), + )), ), ], ); diff --git a/lib/pages/orbits_page.dart b/lib/pages/orbits_page.dart index caaeedb..9bf130f 100644 --- a/lib/pages/orbits_page.dart +++ b/lib/pages/orbits_page.dart @@ -8,6 +8,8 @@ import 'package:lg_space_visualizations/widget/button.dart'; import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/info_box.dart'; import 'package:lg_space_visualizations/widget/orbit_box.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// A page that displays a grid of orbits with an general overview of orbits. class OrbitsPage extends StatefulWidget { @@ -18,11 +20,27 @@ class OrbitsPage extends StatefulWidget { } class _OrbitsPageState extends State { + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + final GlobalKey _threeShowCase = GlobalKey(); + @override void initState() { super.initState(); // Set the Liquid Galaxy to Earth mode lgConnection.setPlanet('earth'); + + // Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseOrbitsPage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context) + .startShowCase([_oneShowCase, _twoShowCase, _threeShowCase]); + prefs.setBool('showcaseOrbitsPage', false); + }); + } + }); } @override @@ -31,100 +49,117 @@ class _OrbitsPageState extends State { title: orbitsTitle, children: [ Expanded( - flex: 3, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: backgroundColor, - ), - child: Padding( - padding: EdgeInsets.only( - left: spaceBetweenWidgets, - right: spaceBetweenWidgets, - top: spaceBetweenWidgets / 2, - bottom: spaceBetweenWidgets, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - orbitsDescriptionTitle, - style: middleTitle, - textAlign: TextAlign.left, + flex: 3, + child: Showcase( + key: _oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseOrbitsPageTitle, + description: oneShowcaseOrbitsPageDescription, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + color: backgroundColor, + ), + child: Padding( + padding: EdgeInsets.only( + left: spaceBetweenWidgets, + right: spaceBetweenWidgets, + top: spaceBetweenWidgets / 2, + bottom: spaceBetweenWidgets, ), - Text( - orbitsIntroText, - style: smallText, - ), - SizedBox(height: spaceBetweenWidgets), - Row( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - InfoBox( - text: orbitsFirstDataValue, - subText: orbitsFirstDataText, + children: [ + Text( + orbitsDescriptionTitle, + style: middleTitle, + textAlign: TextAlign.left, + ), + Text( + orbitsIntroText, + style: smallText, ), - InfoBox( - text: orbitsSecondDataValue, - subText: orbitsSecondDataText, + SizedBox(height: spaceBetweenWidgets), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InfoBox( + text: orbitsFirstDataValue, + subText: orbitsFirstDataText, + ), + InfoBox( + text: orbitsSecondDataValue, + subText: orbitsSecondDataText, + ), + ], + ), + SizedBox(height: spaceBetweenWidgets), + Text( + orbitsEndText, + style: smallText, + ), + const Spacer(), + Button( + center: false, + padding: const EdgeInsets.only(left: 15), + color: secondaryColor, + borderRadius: BorderRadius.circular(borderRadius), + icon: CustomIcon( + name: 'read', + size: 50, + color: backgroundColor, + ), + text: orbitsLearnMoreText, + onPressed: () { + setState(() { + Navigator.pushNamed(context, '/web', arguments: { + 'url': orbitsUrl, + 'title': orbitsTitle, + }); + }); + }, ), ], ), - SizedBox(height: spaceBetweenWidgets), - Text( - orbitsEndText, - style: smallText, - ), - const Spacer(), - Button( - center: false, - padding: const EdgeInsets.only(left: 15), - color: secondaryColor, - borderRadius: BorderRadius.circular(borderRadius), - icon: CustomIcon( - name: 'read', - size: 50, - color: backgroundColor, - ), - text: orbitsLearnMoreText, - onPressed: () { - setState(() { - Navigator.pushNamed(context, '/web', arguments: { - 'url': orbitsUrl, - 'title': orbitsTitle, - }); - }); - }, - ), - ], + ), ), - ), - ), - ), + )), SizedBox(width: spaceBetweenWidgets), Expanded( - flex: 7, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - ), - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: orbits.length, - itemBuilder: (BuildContext context, int index) { - return OrbitBox(orbit: orbits[index]); - }, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - childAspectRatio: 1.064, - crossAxisSpacing: spaceBetweenWidgets, - mainAxisSpacing: spaceBetweenWidgets, - ), - ), - ), - ), + flex: 7, + child: Showcase( + key: _twoShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseOrbitsPageTitle, + description: twoShowcaseOrbitsPageDescription, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + ), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: orbits.length, + itemBuilder: (BuildContext context, int index) { + return index == 0 + ? Showcase( + key: _threeShowCase, + targetBorderRadius: + BorderRadius.circular(borderRadius), + title: threeShowcaseOrbitsPageTitle, + description: threeShowcaseOrbitsPageDescription, + child: OrbitBox(orbit: orbits[index])) + : OrbitBox(orbit: orbits[index]); + }, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 1.064, + crossAxisSpacing: spaceBetweenWidgets, + mainAxisSpacing: spaceBetweenWidgets, + ), + ), + ))), ], ); } diff --git a/lib/pages/rover_page.dart b/lib/pages/rover_page.dart index f3b2548..05284fb 100644 --- a/lib/pages/rover_page.dart +++ b/lib/pages/rover_page.dart @@ -11,6 +11,8 @@ import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/info_box.dart'; import 'package:lg_space_visualizations/widget/map.dart'; import 'package:lg_space_visualizations/widget/view_model.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// [RoverPage] is a stateful widget that displays information about the Perseverance Rover. /// @@ -23,10 +25,36 @@ class RoverPage extends StatefulWidget { } class _RoverPageState extends State { + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + final GlobalKey _threeShowCase = GlobalKey(); + final GlobalKey _fourShowCase = GlobalKey(); + final GlobalKey _fiveShowCase = GlobalKey(); + final GlobalKey _sixShowCase = GlobalKey(); + @override void initState() { super.initState(); showVisualizations(); + + // Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseRoverPage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context).startShowCase([ + _oneShowCase, + _twoShowCase, + _threeShowCase, + oneBottomBarMapShowcase!, + _fourShowCase, + _fiveShowCase, + _sixShowCase, + ]); + prefs.setBool('showcaseRoverPage', false); + }); + } + }); } @override @@ -64,63 +92,86 @@ class _RoverPageState extends State { padding: EdgeInsets.all(spaceBetweenWidgets / 2), child: Column( children: [ - const ViewModel( - model: 'assets/models/perseverance_rover.glb', - backgroundImage: 'assets/images/model_background.png', - alt: 'perseverance rover', - cameraOrbit: '-10deg 78deg 5.5m', - ), + Expanded( + child: Showcase( + key: _twoShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseRoverPageTitle, + description: twoShowcaseRoverPageDescription, + child: const ViewModel( + model: 'assets/models/perseverance_rover.glb', + backgroundImage: 'assets/images/model_background.png', + alt: 'perseverance rover', + cameraOrbit: '-10deg 78deg 5.5m', + ))), SizedBox(height: spaceBetweenWidgets - 5), - Button( - color: secondaryColor, - center: false, - text: 'Inspect Rover', - padding: const EdgeInsets.only(left: 25, top: 5, bottom: 5), - borderRadius: BorderRadius.circular(borderRadius), - icon: CustomIcon( - name: 'mechanic', size: 40, color: backgroundColor), - onPressed: () { - setState(() { - Navigator.pushNamed(context, '/web', arguments: { - 'url': inspectRoverUrl, - 'title': 'Inspect Perseverance Rover' - }); - }); - }, - ), + Showcase( + key: _fourShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: fourShowcaseRoverPageTitle, + description: fourShowcaseRoverPageDescription, + child: Button( + color: secondaryColor, + center: false, + text: 'Inspect Rover', + padding: + const EdgeInsets.only(left: 25, top: 5, bottom: 5), + borderRadius: BorderRadius.circular(borderRadius), + icon: CustomIcon( + name: 'mechanic', size: 40, color: backgroundColor), + onPressed: () { + setState(() { + Navigator.pushNamed(context, '/web', arguments: { + 'url': inspectRoverUrl, + 'title': 'Inspect Perseverance Rover' + }); + }); + }, + )), SizedBox(height: spaceBetweenWidgets / 2), - Button( - color: secondaryColor, - center: false, - text: 'See rover cameras', - padding: const EdgeInsets.only(left: 25, top: 5, bottom: 5), - borderRadius: BorderRadius.circular(borderRadius), - icon: CustomIcon( - name: 'cameras', size: 40, color: backgroundColor), - onPressed: () { - setState(() { - Navigator.pushNamed(context, '/cameras'); - }); - }, - ), + Showcase( + key: _fiveShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: fiveShowcaseRoverPageTitle, + description: fiveShowcaseRoverPageDescription, + child: Button( + color: secondaryColor, + center: false, + text: 'See rover cameras', + padding: + const EdgeInsets.only(left: 25, top: 5, bottom: 5), + borderRadius: BorderRadius.circular(borderRadius), + icon: CustomIcon( + name: 'cameras', size: 40, color: backgroundColor), + onPressed: () { + setState(() { + Navigator.pushNamed(context, '/cameras'); + }); + }, + )), SizedBox(height: spaceBetweenWidgets / 2), - Button( - center: false, - color: secondaryColor, - text: 'Learn more about the rover', - padding: const EdgeInsets.only(left: 15), - borderRadius: BorderRadius.circular(borderRadius), - icon: CustomIcon( - name: 'read', size: 50, color: backgroundColor), - onPressed: () { - setState(() { - Navigator.pushNamed(context, '/web', arguments: { - 'url': roverUrl, - 'title': 'Perseverance Rover' - }); - }); - }, - ), + Showcase( + key: _sixShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: sixShowcaseRoverPageTitle, + description: sixShowcaseRoverPageDescription, + child: Button( + center: false, + color: secondaryColor, + text: 'Learn more about the rover', + padding: const EdgeInsets.only(left: 15), + borderRadius: BorderRadius.circular(borderRadius), + icon: CustomIcon( + name: 'read', size: 50, color: backgroundColor), + onPressed: () { + setState(() { + Navigator.pushNamed(context, '/web', arguments: { + 'url': roverUrl, + 'title': 'Perseverance Rover' + }); + }); + }, + )), ], ), ), @@ -143,15 +194,24 @@ class _RoverPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "About", - style: middleTitle, - textAlign: TextAlign.left, - ), - Text( - '$roverIntroText $roverDescriptionText', - style: smallText, - ), + Showcase( + key: _oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseRoverPageTitle, + description: oneShowcaseRoverPageDescription, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "About", + style: middleTitle, + textAlign: TextAlign.left, + ), + Text( + '$roverIntroText $roverDescriptionText', + style: smallText, + ), + ])), SizedBox(height: spaceBetweenWidgets / 4), const Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -166,18 +226,24 @@ class _RoverPageState extends State { ), SizedBox(height: spaceBetweenWidgets / 4), Expanded( - child: Map( - latitude: mapMarsCenterLat, - longitude: mapMarsCenterLong, - zoom: defaultMarsMapZoom, - tilt: defaultMarsMapTilt, - bearing: defaultMarsMapBearing, - minMaxZoomPreference: const MinMaxZoomPreference(11, 14), - bounds: roverLandingBounds, - boost: defaultMarsMapBoost, - orbitTilt: defaultMarsOrbitTilt, - orbitRange: defaultMarsOrbitRange, - kmlName: 'Rover')), + child: Showcase( + key: _threeShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: threeShowcaseRoverPageTitle, + description: threeShowcaseRoverPageDescription, + child: Map( + latitude: mapMarsCenterLat, + longitude: mapMarsCenterLong, + zoom: defaultMarsMapZoom, + tilt: defaultMarsMapTilt, + bearing: defaultMarsMapBearing, + minMaxZoomPreference: + const MinMaxZoomPreference(11, 14), + bounds: roverLandingBounds, + boost: defaultMarsMapBoost, + orbitTilt: defaultMarsOrbitTilt, + orbitRange: defaultMarsOrbitRange, + kmlName: 'Rover'))), ], ), ), diff --git a/lib/pages/services_page.dart b/lib/pages/services_page.dart index 6deaed3..0f61f7f 100644 --- a/lib/pages/services_page.dart +++ b/lib/pages/services_page.dart @@ -6,6 +6,8 @@ import 'package:lg_space_visualizations/utils/text_constants.dart'; import 'package:lg_space_visualizations/widget/button.dart'; import 'package:lg_space_visualizations/widget/custom_dialog.dart'; import 'package:lg_space_visualizations/widget/custom_icon.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// A [ServicesPage] widget for managing services related to the Liquid Galaxy system. /// @@ -18,6 +20,26 @@ class ServicesPage extends StatefulWidget { } class _ServicesPageState extends State { + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + + @override + void initState() { + super.initState(); + + // Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseServicesPage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context) + .startShowCase([_oneShowCase, _twoShowCase]); + prefs.setBool('showcaseServicesPage', false); + }); + } + }); + } + /// Displays a dialog indicating that the Liquid Galaxy is not connected. void showNotConnectedDialog(BuildContext context) { showDialog( @@ -49,153 +71,164 @@ class _ServicesPageState extends State { right: spaceBetweenWidgets, bottom: spaceBetweenWidgets, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Row( + child: Showcase( + key: _oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseServicesPageTitle, + description: oneShowcaseServicesPageDescription, + child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildServiceButton( - context, - icon: 'relaunch', - text: relaunchTitle, - onPressed: () async { - if (await lgConnection.isConnected()) { - lgConnection.relaunch(); - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: relaunchMessageTitle, - content: relaunchSuccessMessage, - iconName: 'relaunch', - ); - }, - ); - } else { - showNotConnectedDialog(context); - } - }, - ), - _buildServiceButton( - context, - icon: 'clear', - text: clearKmlTitle, - onPressed: () async { - if (await lgConnection.isConnected()) { - lgConnection.clearKml(keepLogos: true); - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: clearKmlMessageTitle, - content: clearKmlSuccessMessage, - iconName: 'clear', + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Showcase( + key: _twoShowCase, + targetBorderRadius: + BorderRadius.circular(borderRadius), + title: twoShowcaseServicesPageTitle, + description: twoShowcaseServicesPageDescription, + child: _buildServiceButton( + context, + icon: 'relaunch', + text: relaunchTitle, + onPressed: () async { + if (await lgConnection.isConnected()) { + lgConnection.relaunch(); + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: relaunchMessageTitle, + content: relaunchSuccessMessage, + iconName: 'relaunch', + ); + }, + ); + } else { + showNotConnectedDialog(context); + } + }, + )), + _buildServiceButton( + context, + icon: 'clear', + text: clearKmlTitle, + onPressed: () async { + if (await lgConnection.isConnected()) { + lgConnection.clearKml(keepLogos: true); + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: clearKmlMessageTitle, + content: clearKmlSuccessMessage, + iconName: 'clear', + ); + }, ); - }, - ); - } else { - showNotConnectedDialog(context); - } - }, - ), - _buildServiceButton( - context, - icon: 'reboot', - text: rebootTitle, - onPressed: () async { - if (await lgConnection.isConnected()) { - lgConnection.reboot(); - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: rebootMessageTitle, - content: rebootSuccessMessage, - iconName: 'reboot', + } else { + showNotConnectedDialog(context); + } + }, + ), + _buildServiceButton( + context, + icon: 'reboot', + text: rebootTitle, + onPressed: () async { + if (await lgConnection.isConnected()) { + lgConnection.reboot(); + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: rebootMessageTitle, + content: rebootSuccessMessage, + iconName: 'reboot', + ); + }, ); - }, - ); - } else { - showNotConnectedDialog(context); - } - }, + } else { + showNotConnectedDialog(context); + } + }, + ), + ], ), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildServiceButton( - context, - icon: 'shutdown', - text: shutdownTitle, - onPressed: () async { - if (await lgConnection.isConnected()) { - lgConnection.shutdown(); - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: shutdownMessageTitle, - content: shutdownSuccessMessage, - iconName: 'shutdown', + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildServiceButton( + context, + icon: 'shutdown', + text: shutdownTitle, + onPressed: () async { + if (await lgConnection.isConnected()) { + lgConnection.shutdown(); + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: shutdownMessageTitle, + content: shutdownSuccessMessage, + iconName: 'shutdown', + ); + }, ); - }, - ); - } else { - showNotConnectedDialog(context); - } - }, - ), - _buildServiceButton( - context, - icon: 'see', - text: showLogosTitle, - onPressed: () async { - if (await lgConnection.isConnected()) { - lgConnection.showLogos(); - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: showLogosMessageTitle, - content: showLogosSuccessMessage, - iconName: 'see', + } else { + showNotConnectedDialog(context); + } + }, + ), + _buildServiceButton( + context, + icon: 'see', + text: showLogosTitle, + onPressed: () async { + if (await lgConnection.isConnected()) { + lgConnection.showLogos(); + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: showLogosMessageTitle, + content: showLogosSuccessMessage, + iconName: 'see', + ); + }, ); - }, - ); - } else { - showNotConnectedDialog(context); - } - }, - ), - _buildServiceButton( - context, - icon: 'hide', - text: hideLogosTitle, - onPressed: () async { - if (await lgConnection.isConnected()) { - lgConnection.clearKml(keepLogos: false); - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: hideLogosMessageTitle, - content: hideLogosSuccessMessage, - iconName: 'hide', + } else { + showNotConnectedDialog(context); + } + }, + ), + _buildServiceButton( + context, + icon: 'hide', + text: hideLogosTitle, + onPressed: () async { + if (await lgConnection.isConnected()) { + lgConnection.clearKml(keepLogos: false); + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: hideLogosMessageTitle, + content: hideLogosSuccessMessage, + iconName: 'hide', + ); + }, ); - }, - ); - } else { - showNotConnectedDialog(context); - } - }, + } else { + showNotConnectedDialog(context); + } + }, + ), + ], ), ], - ), - ], - ), + )), ), ), ], diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 66d7d2f..742728b 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -9,6 +9,7 @@ import 'package:lg_space_visualizations/widget/custom_dialog.dart'; import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/input.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; /// A [SettingsPage] widget for configuring settings related to the Liquid Galaxy connection. class SettingsPage extends StatefulWidget { @@ -37,6 +38,10 @@ class _SettingsPageState extends State { /// Controller for the NASA API key input field. final TextEditingController apiKeyController = TextEditingController(); + /// The showcase keys + final GlobalKey _oneShowCase = GlobalKey(); + final GlobalKey _twoShowCase = GlobalKey(); + /// Updates the input fields with saved preferences data. updateFields() async { SharedPreferences prefs = await SharedPreferences.getInstance(); @@ -53,6 +58,19 @@ class _SettingsPageState extends State { void initState() { super.initState(); updateFields(); + + // Show the showcase tutorial if it's the first time the user opens the page + SharedPreferences.getInstance().then((prefs) { + if (prefs.getBool('showcaseSettingsPage') ?? true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ShowCaseWidget.of(context).startShowCase([ + _oneShowCase, + _twoShowCase, + ]); + prefs.setBool('showcaseSettingsPage', false); + }); + } + }); } @override @@ -86,118 +104,130 @@ class _SettingsPageState extends State { ), SizedBox(height: spaceBetweenWidgets / 2), Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _buildRow(context, - icon: 'user', - label: usernameLabel, - controller: usernameController, - hintText: usernameHint, - inputType: TextInputType.text, - id: 'lg_username', - dialogTitle: usernameLabel, - dialogContent: usernameDialogContent), - SizedBox(height: spaceBetweenWidgets / 1.5), - _buildRow(context, - icon: 'ip', - label: ipLabel, - controller: ipController, - hintText: ipHint, - inputType: TextInputType.number, - id: 'lg_ip', - dialogTitle: ipLabel, - dialogContent: ipDialogContent), - SizedBox(height: spaceBetweenWidgets / 1.5), - _buildRow(context, - icon: 'ethernet', - label: portLabel, - controller: portController, - hintText: portHint, - inputType: TextInputType.number, - id: 'lg_port', - dialogTitle: 'Port', - dialogContent: portDialogContent), - SizedBox(height: spaceBetweenWidgets / 1.5), - _buildRow(context, - icon: 'locker', - label: passwordLabel, - controller: passwordController, - hintText: passwordHint, - inputType: TextInputType.text, - id: 'lg_password', - secure: true, - dialogTitle: passwordLabel, - dialogContent: passwordDialogContent), - SizedBox(height: spaceBetweenWidgets / 1.5), - _buildRow(context, - icon: 'screens', - label: screenNumberLabel, - controller: nScreensController, - hintText: screenNumberHint, - inputType: TextInputType.number, - id: 'lg_screen_amount', - secure: false, - dialogTitle: screenNumberLabel, - dialogContent: screenNumberDialogContent), - SizedBox(height: spaceBetweenWidgets / 1.5), - _buildRow(context, - icon: 'api', - label: apiLabel, - controller: apiKeyController, - hintText: apiHint, - inputType: TextInputType.text, - id: 'nasa_api_key_unchecked', - secure: true, - dialogTitle: apiLabel, - dialogContent: apiDialogContent, - onSubmitted: (key) async { - if (key.isEmpty) { - showDialog( - context: context, - builder: (BuildContext context) { + child: Showcase( + key: _oneShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseSettingsPageTitle, + description: oneShowcaseSettingsPageDescription, + child: SingleChildScrollView( + child: Column( + children: [ + _buildRow(context, + icon: 'user', + label: usernameLabel, + controller: usernameController, + hintText: usernameHint, + inputType: TextInputType.text, + id: 'lg_username', + dialogTitle: usernameLabel, + dialogContent: usernameDialogContent), + SizedBox(height: spaceBetweenWidgets / 1.5), + _buildRow(context, + icon: 'ip', + label: ipLabel, + controller: ipController, + hintText: ipHint, + inputType: TextInputType.number, + id: 'lg_ip', + dialogTitle: ipLabel, + dialogContent: ipDialogContent), + SizedBox(height: spaceBetweenWidgets / 1.5), + _buildRow(context, + icon: 'ethernet', + label: portLabel, + controller: portController, + hintText: portHint, + inputType: TextInputType.number, + id: 'lg_port', + dialogTitle: 'Port', + dialogContent: portDialogContent), + SizedBox(height: spaceBetweenWidgets / 1.5), + _buildRow(context, + icon: 'locker', + label: passwordLabel, + controller: passwordController, + hintText: passwordHint, + inputType: TextInputType.text, + id: 'lg_password', + secure: true, + dialogTitle: passwordLabel, + dialogContent: passwordDialogContent), + SizedBox(height: spaceBetweenWidgets / 1.5), + _buildRow(context, + icon: 'screens', + label: screenNumberLabel, + controller: nScreensController, + hintText: screenNumberHint, + inputType: TextInputType.number, + id: 'lg_screen_amount', + secure: false, + dialogTitle: screenNumberLabel, + dialogContent: screenNumberDialogContent), + SizedBox(height: spaceBetweenWidgets / 1.5), + _buildRow(context, + icon: 'api', + label: apiLabel, + controller: apiKeyController, + hintText: apiHint, + inputType: TextInputType.text, + id: 'nasa_api_key_unchecked', + secure: true, + dialogTitle: apiLabel, + dialogContent: apiDialogContent, + onSubmitted: (key) async { + if (key.isEmpty) { + showDialog( + context: context, + builder: (BuildContext context) { + SharedPreferences.getInstance() + .then((prefs) { + prefs.remove('nasa_api_key'); + }); + return CustomDialog( + title: apiSaveSuccessMessage, + content: defaultKeyApiMessage, + iconName: 'api', + ); + }, + ); + } else if (await NasaApi.isApiKeyValid(key)) { SharedPreferences.getInstance().then((prefs) { - prefs.remove('nasa_api_key'); + prefs.setString('nasa_api_key', key); }); - return CustomDialog( - title: apiSaveSuccessMessage, - content: defaultKeyApiMessage, - iconName: 'api', + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: apiSaveSuccessMessage, + content: customApiSavedMessage, + iconName: 'api', + ); + }, ); - }, - ); - } else if (await NasaApi.isApiKeyValid(key)) { - SharedPreferences.getInstance().then((prefs) { - prefs.setString('nasa_api_key', key); - }); - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: apiSaveSuccessMessage, - content: customApiSavedMessage, - iconName: 'api', + } else { + showDialog( + context: context, + builder: (BuildContext context) { + return CustomDialog( + title: apiInvalidTitle, + content: apiInvalidMessage, + iconName: 'error', + ); + }, ); - }, - ); - } else { - showDialog( - context: context, - builder: (BuildContext context) { - return CustomDialog( - title: apiInvalidTitle, - content: apiInvalidMessage, - iconName: 'error', - ); - }, - ); - } - }), - ], - ), - ), + } + }), + ], + ), + )), ), - if (!isKeyboardVisible) _buildButtons(context), + if (!isKeyboardVisible) + Showcase( + key: _twoShowCase, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseSettingsPageTitle, + description: twoShowcaseSettingsPageDescription, + child: _buildButtons(context)), ], ), ), diff --git a/lib/utils/routes.dart b/lib/utils/routes.dart index ac419a4..6250a31 100644 --- a/lib/utils/routes.dart +++ b/lib/utils/routes.dart @@ -20,7 +20,7 @@ import 'package:lg_space_visualizations/utils/sol_day.dart'; /// This function takes [settings] as a parameter, which contains the name of /// the route to be generated. It returns a [Route] corresponding to the route /// name. If the route name is not recognized, it defaults to the home page. -Route makeRoute(RouteSettings settings) { +Route? makeRoute(RouteSettings settings) { WidgetBuilder builder; switch (settings.name) { case '/home': @@ -114,8 +114,7 @@ Route makeRoute(RouteSettings settings) { } break; default: - // Default route if no match is found, redirects to the home page. - builder = (BuildContext context) => const HomePage(); + return null; } // Return a custom route with a very short transition animation. return AnimationPageRoute(builder: builder, settings: settings); diff --git a/lib/utils/text_constants.dart b/lib/utils/text_constants.dart index 6307761..7849bf1 100644 --- a/lib/utils/text_constants.dart +++ b/lib/utils/text_constants.dart @@ -8,7 +8,12 @@ String infoPageTitle = 'Info'; String projectTitle = 'Space Visualizations'; String projectSubtitle = 'for Liquid Galaxy'; String infoPageDescription = - '''This project aims to build an educational application dedicated to visualizing orbits and the Mars 2020 mission, utilizing the Liquid Galaxy platform to provide immersive space exploration experiences. The app enables users to see and understand different orbits, such as GPS, QZSS, Graveyard and more in detail. Additionally, it showcases the Mars 2020 mission by featuring interactive 3D models of the Perseverance Rover and the Ingenuity Drone. Users can follow their paths on Mars, view photos taken by the rover, and discover technical details about the mission.'''; + '''Space Visualizations for Liquid Galaxy is an application that showcases the Mars 2020 NASA mission and some of the most famous Earth orbits. The application uses the Liquid Galaxy platform to provide immersive space exploration experiences. In the Mars mission section, users can interactively learn about the mission by visualizing 3D models, technical data, and the path of the Perseverance rover and Ingenuity drone. Users can see Mars from the perspective of the Perseverance rover with more than 220000 photos available. In the Earth orbit section, a list of orbits can be displayed in both the application and, with a realistic representation, on Liquid Galaxy Google Earth. Users can interact with these orbits and learn more about them.'''; +String infoPageFooter = + '''This project has been started as a Google Summer of Code 2024 project with the Liquid Galaxy Org +Developed by Mattia Baggini +Mentor: Victor Sanchez +Liquid Galaxy Org Director: Andreu IbaΓ±ez'''; /// Texts for the rover page String roverIntroText = @@ -227,3 +232,129 @@ String camerasImagesErrorText = 'Error:'; String camerasImagesLoadingText = 'Fetching photos from NASA API...'; String camerasImagesTaken = 'Taken with'; String displayOnLGButtonText = 'Display on Liquid Galaxy'; + +/// Texts for showcase +String oneShowcaseHomePageTitle = 'Mars Section'; +String oneShowcaseHomePageDescription = + 'Here you can explore Mars 2020 Mission'; +String twoShowcaseHomePageTitle = 'Earth Section'; +String twoShowcaseHomePageDescription = 'Here you can explore Earth orbits'; + +String oneShowcaseBottomBarTitle = 'Settings'; +String oneShowcaseBottomBarDescription = + 'Here you can connect the application with the Liquid Galaxy'; +String twoShowcaseBottomBarTitle = 'Services'; +String twoShowcaseBottomBarDescription = + 'Here you can send service commands to the Liquid Galaxy'; +String threeShowcaseBottomBarTitle = 'Info'; +String threeShowcaseBottomBarDescription = + 'Here you can learn more about the application'; +String fourShowcaseBottomBarTitle = 'Led Status'; +String fourShowcaseBottomBarDescription = + 'Here you check the connection status with the Liquid Galaxy, green means connected and red means disconnected'; +String fiveShowcaseBottomBarTitle = 'Home page'; +String fiveShowcaseBottomBarDescription = + 'Here you can go back to the home page'; + +String oneShowcaseServicesPageTitle = 'Liquid Galaxy Services'; +String oneShowcaseServicesPageDescription = + 'Here you have a grid of services that you can use to manage the Liquid Galaxy system.'; +String twoShowcaseServicesPageTitle = 'Liquid Galaxy Service'; +String twoShowcaseServicesPageDescription = + 'Click on the button to send the command to the Liquid Galaxy system.'; + +String oneShowcaseSettingsPageTitle = 'LG Connection'; +String oneShowcaseSettingsPageDescription = + 'Here you can insert the Liquid Galaxy connection details or insert a custom NASA API key'; +String twoShowcaseSettingsPageTitle = 'LG Connection'; +String twoShowcaseSettingsPageDescription = + 'Here you can connect/disconnect the application with the Liquid Galaxy'; + +String oneShowcaseOrbitsPageTitle = 'Orbit Description'; +String oneShowcaseOrbitsPageDescription = + 'Here you can read a general overview of orbits and learn more about them'; +String twoShowcaseOrbitsPageTitle = 'Orbits'; +String twoShowcaseOrbitsPageDescription = + 'Those are some of the most important orbits around Earth'; +String threeShowcaseOrbitsPageTitle = 'Orbit'; +String threeShowcaseOrbitsPageDescription = + 'Click on an orbit to learn more about it'; + +String oneShowcaseOrbitPageTitle = 'Orbit Description'; +String oneShowcaseOrbitPageDescription1 = + 'Here you can read a description of the'; +String oneShowcaseOrbitPageDescription2 = 'orbit and its'; +String oneShowcaseOrbitPageDescription3 = 'relative'; +String oneShowcaseOrbitPageDescription4 = 'satellite'; +String twoShowcaseOrbitPageTitle = 'Orbit Map'; +String twoShowcaseOrbitPageDescription = + 'Here you can see the path of the orbit in a 2D map'; + +String oneShowcaseRoverPageTitle = 'Rover information'; +String oneShowcaseRoverPageDescription = + 'Here you can read some information about the Perseverance rover'; +String twoShowcaseRoverPageTitle = 'Rover 3D model'; +String twoShowcaseRoverPageDescription = + 'Here you can see a interactive 3D model of the Perseverance rover'; +String threeShowcaseRoverPageTitle = 'Rover path'; +String threeShowcaseRoverPageDescription = + 'Here you can see the path of the Perseverance rover in a interactive map'; +String fourShowcaseRoverPageTitle = 'Inspect Rover'; +String fourShowcaseRoverPageDescription = + 'Here you can inspect the Perseverance rover in a interactive page'; +String fiveShowcaseRoverPageTitle = 'Rover cameras'; +String fiveShowcaseRoverPageDescription = + 'Here you can see the real photos taken by the Perseverance rover'; +String sixShowcaseRoverPageTitle = 'Read more'; +String sixShowcaseRoverPageDescription = + 'Here you can read more about the Perseverance rover from Nasa page'; + +String oneShowcaseMapTitle = 'Orbit'; +String twoShowcaseMapDescription = + 'Click here to orbit the map on the Liquid Galaxy'; + +String oneShowcaseDronePageTitle = 'Drone information'; +String oneShowcaseDronePageDescription = + 'Here you can read some information about the Ingenuity drone'; +String twoShowcaseDronePageTitle = 'Drone 3D model'; +String twoShowcaseDronePageDescription = + 'Here you can see a interactive 3D model of the Ingenuity drone'; +String threeShowcaseDronePageTitle = 'Drone path'; +String threeShowcaseDronePageDescription = + 'Here you can see the path of the Ingenuity drone in a interactive map'; +String fourShowcaseDronePageTitle = 'Read more'; +String fourShowcaseDronePageDescription = + 'Here you can read more about the Ingenuity Drone from Nasa page'; +String fiveShowcaseDronePageTitle = 'Meet Perseverance'; +String fiveShowcaseDronePageDescription = + 'Here you can see the Perseverance Rover and learn more about it'; + +String oneShowcaseCamerasPageTitle = 'Days List'; +String oneShowcaseCamerasPageDescription = + 'Here you can see the list of days with photos taken by the Perseverance Rover'; +String twoShowcaseCamerasPageTitle = 'Update list'; +String twoShowcaseCamerasPageDescription = + 'Here you can update the list of days with photos taken by the Perseverance Rover using Nasa API'; +String threeShowcaseCamerasPageTitle = 'View cameras'; +String threeShowcaseCamerasPageDescription = + 'Here you can see the positions of the cameras on the Perseverance Rover'; +String fourShowcaseCamerasPageTitle = 'Filter list'; +String fourShowcaseCamerasPageDescription = + 'Here you can filter the list of days by different parameters'; + +String oneShowcaseDaysListTitle = 'Day'; +String oneShowcaseDaysListDescription = + 'Select a day to see the photos from that day'; + +String oneShowcaseCamerasFiltersTitle = 'Dates filter'; +String oneShowcaseCamerasFiltersDescription = + 'Here you can select a range of dates to filter the photos taken by the rover'; +String twoShowcaseCamerasFiltersTitle = 'Photos number filter'; +String twoShowcaseCamerasFiltersDescription = + 'Here you can slide to select a range of photos to filter the photos'; +String threeShowcaseCamerasFiltersTitle = 'Cameras filter'; +String threeShowcaseCamerasFiltersDescription = + 'Here you can select the cameras to filter the photos'; +String fourShowcaseCamerasFiltersTitle = 'Show result'; +String fourShowcaseCamerasFiltersDescription = + 'Click here to show the results of the filter'; diff --git a/lib/widget/bottom_bar.dart b/lib/widget/bottom_bar.dart index b24ca1c..5f17c54 100644 --- a/lib/widget/bottom_bar.dart +++ b/lib/widget/bottom_bar.dart @@ -1,9 +1,18 @@ import 'package:flutter/material.dart'; import 'package:lg_space_visualizations/utils/lg_connection.dart'; import 'package:lg_space_visualizations/utils/styles.dart'; +import 'package:lg_space_visualizations/utils/text_constants.dart'; import 'package:lg_space_visualizations/widget/button.dart'; import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/led_status.dart'; +import 'package:showcaseview/showcaseview.dart'; + +/// Global keys for the showcase of the [BottomBar] widget +GlobalKey? oneBottomBar; +GlobalKey? twoBottomBar; +GlobalKey? threeBottomBar; +GlobalKey? fourBottomBar; +GlobalKey? fiveBottomBar; /// Bottom navigation bar with various buttons and a status indicator. /// @@ -17,9 +26,26 @@ class BottomBar extends StatefulWidget { } class _BottomBarState extends State { + /// The showcase keys + final GlobalKey _oneBottomBar = GlobalKey(); + final GlobalKey _twoBottomBar = GlobalKey(); + final GlobalKey _threeBottomBar = GlobalKey(); + final GlobalKey _fourBottomBar = GlobalKey(); + final GlobalKey _fiveBottomBar = GlobalKey(); + + @override + void initState() { + super.initState(); + } + @override Widget build(BuildContext context) { final currentRoute = ModalRoute.of(context)?.settings.name; + oneBottomBar = _oneBottomBar; + twoBottomBar = _twoBottomBar; + threeBottomBar = _threeBottomBar; + fourBottomBar = _fourBottomBar; + fiveBottomBar = _fiveBottomBar; return Container( margin: EdgeInsets.only( @@ -37,79 +63,105 @@ class _BottomBarState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Button( - borderRadius: BorderRadius.circular(borderRadius), - color: secondaryColor, - icon: CustomIcon( - name: 'home', - size: 35, - color: backgroundColor, - ), - padding: const EdgeInsets.only( - left: 25, right: 25, top: 8, bottom: 8), - onPressed: () { - // Navigate to home only if the current route is not home ('/') - if (currentRoute != '/') { - Navigator.pushNamed(context, '/'); - } - }), + Showcase( + key: fiveBottomBar!, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: fiveShowcaseBottomBarTitle, + description: fiveShowcaseBottomBarDescription, + child: Button( + borderRadius: BorderRadius.circular(borderRadius), + color: secondaryColor, + icon: CustomIcon( + name: 'home', + size: 35, + color: backgroundColor, + ), + padding: const EdgeInsets.only( + left: 25, right: 25, top: 8, bottom: 8), + onPressed: () { + // Navigate to home only if the current route is not home ('/') + if (currentRoute != '/home') { + Navigator.pushNamed(context, '/home'); + } + })), const Spacer(), - Button( - color: Colors.transparent, - borderRadius: BorderRadius.circular(0), - icon: CustomIcon( - name: "settings", - color: secondaryColor, - size: 40, - ), - onPressed: () { - // Navigate to settings only if the current route is not settings ('/settings') - if (currentRoute != '/settings') { - Navigator.pushNamed(context, '/settings'); - } - }), + Showcase( + key: oneBottomBar!, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseBottomBarTitle, + description: oneShowcaseBottomBarDescription, + child: Button( + color: Colors.transparent, + borderRadius: BorderRadius.circular(0), + icon: CustomIcon( + name: "settings", + color: secondaryColor, + size: 40, + ), + onPressed: () { + // Navigate to settings only if the current route is not settings ('/settings') + if (currentRoute != '/settings') { + Navigator.pushNamed(context, '/settings'); + } + })), Container(width: 35), - Button( - color: Colors.transparent, - borderRadius: BorderRadius.circular(0), - icon: CustomIcon( - name: "services", - color: secondaryColor, - size: 40, - ), - onPressed: () { - // Navigate to service only if the current route is not service ('/service') - if (currentRoute != '/services') { - Navigator.pushNamed(context, '/services'); - } - }), + Showcase( + key: twoBottomBar!, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: twoShowcaseBottomBarTitle, + description: twoShowcaseBottomBarDescription, + child: Button( + color: Colors.transparent, + borderRadius: BorderRadius.circular(0), + icon: CustomIcon( + name: "services", + color: secondaryColor, + size: 40, + ), + onPressed: () { + // Navigate to service only if the current route is not service ('/service') + if (currentRoute != '/services') { + Navigator.pushNamed(context, '/services'); + } + })), Container(width: 35), - Button( - color: Colors.transparent, - borderRadius: BorderRadius.circular(0), - icon: CustomIcon( - name: "info", - color: secondaryColor, - size: 40, - ), - onPressed: () { - // Navigate to info only if the current route is not info ('/info') - if (currentRoute != '/info') { - Navigator.pushNamed(context, '/info'); - } - }), + Showcase( + key: threeBottomBar!, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: threeShowcaseBottomBarTitle, + description: threeShowcaseBottomBarDescription, + child: Button( + color: Colors.transparent, + borderRadius: BorderRadius.circular(0), + icon: CustomIcon( + name: "info", + color: secondaryColor, + size: 40, + ), + onPressed: () { + // Navigate to info only if the current route is not info ('/info') + if (currentRoute != '/info') { + Navigator.pushNamed(context, '/info'); + } + })), Container(width: 40), - FutureBuilder( - future: lgConnection.isConnected(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - return LedStatus(status: snapshot.data!, size: 35); - } else { - return const LedStatus(status: false, size: 35, enable: false); - } - }, - ), + Showcase( + key: fourBottomBar!, + targetShapeBorder: const CircleBorder(), + title: fourShowcaseBottomBarTitle, + description: fourShowcaseBottomBarDescription, + child: FutureBuilder( + future: lgConnection.isConnected(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + return LedStatus(status: snapshot.data!, size: 35); + } else { + return const LedStatus( + status: false, size: 35, enable: false); + } + }, + )), ], )), ); diff --git a/lib/widget/days_list.dart b/lib/widget/days_list.dart index fbd309b..9e9c108 100644 --- a/lib/widget/days_list.dart +++ b/lib/widget/days_list.dart @@ -5,6 +5,10 @@ import 'package:lg_space_visualizations/utils/styles.dart'; import 'package:lg_space_visualizations/utils/text_constants.dart'; import 'package:lg_space_visualizations/widget/custom_icon.dart'; import 'package:lg_space_visualizations/widget/custom_scrollbar.dart'; +import 'package:showcaseview/showcaseview.dart'; + +/// Global key for the showcase of the [DaysList] widget +GlobalKey? oneDaysListShowcase; /// A Widget that displays a list of SolDays filtered based on the provided filter class DaysList extends StatelessWidget { @@ -17,12 +21,16 @@ class DaysList extends StatelessWidget { /// Filter object to filter SolDay objects final Filter filter; + /// The showcase key + final GlobalKey _oneDaysListShowcase = GlobalKey(); + DaysList({super.key, required this.allDays, required this.filter}); @override Widget build(BuildContext context) { // Filtering the list of SolDay objects based on the filter filteredDays = allDays.where(filter.isValidDay).toList(); + oneDaysListShowcase = _oneDaysListShowcase; return filteredDays.isEmpty ? buildNotFound() @@ -47,90 +55,33 @@ class DaysList extends StatelessWidget { itemBuilder: (context, index) { // Getting the SolDay object at the current index SolDay day = filteredDays[index]; - + return index == 0 + ? Showcase( + key: oneDaysListShowcase!, + targetBorderRadius: BorderRadius.circular(borderRadius), + title: oneShowcaseDaysListTitle, + description: oneShowcaseDaysListDescription, + child: DayListItem( + day: day, + filter: filter, + spaceBetweenWidgets: spaceBetweenWidgets, + borderRadius: borderRadius, + secondaryColor: secondaryColor, + backgroundColor: backgroundColor, + middleTitle: middleTitle, + bigText: bigText, + )) + : DayListItem( + day: day, + filter: filter, + spaceBetweenWidgets: spaceBetweenWidgets, + borderRadius: borderRadius, + secondaryColor: secondaryColor, + backgroundColor: backgroundColor, + middleTitle: middleTitle, + bigText: bigText, + ); // Navigating to the cameras images page on tap - return GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/cameras_images', - arguments: [day, filter.camerasSelected]); - }, - child: Container( - padding: EdgeInsets.only( - left: spaceBetweenWidgets, - right: spaceBetweenWidgets, - top: spaceBetweenWidgets / 2, - bottom: spaceBetweenWidgets / 2, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(borderRadius), - color: secondaryColor.withOpacity(0.3)), - child: Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text('Sol ${day.sol}', style: middleTitle), - Transform.translate( - offset: const Offset(0, -5), - child: Text( - '$daysListSubtitle ${SolDay.getFormattedEarthDate(day.earthDate)}', // Displaying the formatted Earth date - )) - ], - ), - const Spacer(), - Tooltip( - message: toolTipTotalPhotos, - child: Container( - width: 110, - decoration: BoxDecoration( - color: secondaryColor, - borderRadius: - BorderRadius.circular(borderRadius), - ), - padding: - EdgeInsets.all(spaceBetweenWidgets / 2), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text('${day.totalPhotos}', - style: bigText.apply( - color: backgroundColor)), - CustomIcon( - name: 'image', - size: 40, - color: backgroundColor), - ], - ), - )), - SizedBox(width: spaceBetweenWidgets), - Tooltip( - message: toolTipCameras, - child: Container( - width: 90, - decoration: BoxDecoration( - color: secondaryColor, - borderRadius: - BorderRadius.circular(borderRadius)), - padding: - EdgeInsets.all(spaceBetweenWidgets / 2), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text('${day.cameras.length}', - style: bigText.apply( - color: backgroundColor)), - CustomIcon( - name: 'camera', - size: 40, - color: backgroundColor), - ], - ), - )) - ], - ))); }, separatorBuilder: (BuildContext context, int index) { return SizedBox(height: spaceBetweenWidgets / 2); @@ -152,3 +103,110 @@ class DaysList extends StatelessWidget { )); } } + +/// A widget that represents an item in the list. +/// +/// The item required a [day], [filter], [spaceBetweenWidgets], [borderRadius], +/// [secondaryColor], [backgroundColor], [middleTitle] and [bigText]. +class DayListItem extends StatelessWidget { + final SolDay day; + final Filter filter; + final double spaceBetweenWidgets; + final double borderRadius; + final Color secondaryColor; + final Color backgroundColor; + final TextStyle middleTitle; + final TextStyle bigText; + + const DayListItem({ + super.key, + required this.day, + required this.filter, + required this.spaceBetweenWidgets, + required this.borderRadius, + required this.secondaryColor, + required this.backgroundColor, + required this.middleTitle, + required this.bigText, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/cameras_images', + arguments: [day, filter.camerasSelected]); + }, + child: Container( + padding: EdgeInsets.only( + left: spaceBetweenWidgets, + right: spaceBetweenWidgets, + top: spaceBetweenWidgets / 2, + bottom: spaceBetweenWidgets / 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + color: secondaryColor.withOpacity(0.3), + ), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Text('Sol ${day.sol}', style: middleTitle), + Transform.translate( + offset: const Offset(0, -5), + child: Text( + '$daysListSubtitle ${SolDay.getFormattedEarthDate(day.earthDate)}', + ), + ), + ], + ), + const Spacer(), + Tooltip( + message: toolTipTotalPhotos, + child: Container( + width: 110, + decoration: BoxDecoration( + color: secondaryColor, + borderRadius: BorderRadius.circular(borderRadius), + ), + padding: EdgeInsets.all(spaceBetweenWidgets / 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${day.totalPhotos}', + style: bigText.apply(color: backgroundColor)), + CustomIcon(name: 'image', size: 40, color: backgroundColor), + ], + ), + ), + ), + SizedBox(width: spaceBetweenWidgets), + Tooltip( + message: toolTipCameras, + child: Container( + width: 90, + decoration: BoxDecoration( + color: secondaryColor, + borderRadius: BorderRadius.circular(borderRadius), + ), + padding: EdgeInsets.all(spaceBetweenWidgets / 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${day.cameras.length}', + style: bigText.apply(color: backgroundColor)), + CustomIcon( + name: 'camera', size: 40, color: backgroundColor), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widget/map.dart b/lib/widget/map.dart index 1c8fd35..d058840 100644 --- a/lib/widget/map.dart +++ b/lib/widget/map.dart @@ -11,6 +11,10 @@ import 'package:lg_space_visualizations/utils/styles.dart'; import 'package:lg_space_visualizations/utils/text_constants.dart'; import 'package:lg_space_visualizations/widget/button.dart'; import 'package:lg_space_visualizations/widget/custom_icon.dart'; +import 'package:showcaseview/showcaseview.dart'; + +/// Global key for the showcase of the [Map] widget +GlobalKey? oneBottomBarMapShowcase; /// The [Map] widget displays a Google Map with a specific [latitude], [longitude], [tilt], [bearing] and [zoom] level. The zoom level can be adjusted using the [boost] parameter. /// @@ -86,6 +90,7 @@ class _MapState extends State with SingleTickerProviderStateMixin { late AnimationController rotationOrbitController; // Controller for the Icon rotation animation bool isOrbiting = false; // Flag to check if the map is currently orbiting + final GlobalKey _oneBottomBarMapShowcase = GlobalKey(); @override void initState() { @@ -246,6 +251,7 @@ class _MapState extends State with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { + oneBottomBarMapShowcase = _oneBottomBarMapShowcase; return Stack(children: [ Positioned.fill( child: Container( @@ -285,39 +291,46 @@ class _MapState extends State with SingleTickerProviderStateMixin { Positioned( bottom: 20, right: 20, - child: Tooltip( - message: toolTipMapOrbitText, - child: RotationTransition( - turns: Tween(begin: 0.0, end: 25.0) - .animate(rotationOrbitController), - child: Builder(builder: (context) { - return Container( - decoration: BoxDecoration( - color: backgroundColor, - border: Border.all( - color: secondaryColor, - width: 3, - ), - borderRadius: BorderRadius.circular(50), - ), - child: Button( - padding: const EdgeInsets.all(8), - icon: CustomIcon( - name: 'orbit', size: 40, color: secondaryColor), - onPressed: () async { - // Start or stop orbiting based on the current state - if (isOrbiting) { - await stopOrbit(); - } else { - await startOrbit(); - } - - // Update the state to reflect the new orbiting state - isOrbiting = !isOrbiting; - })); - }), - ), - )) + child: Showcase( + key: oneBottomBarMapShowcase!, + targetShapeBorder: const CircleBorder(), + title: oneShowcaseMapTitle, + description: twoShowcaseMapDescription, + child: Tooltip( + message: toolTipMapOrbitText, + child: RotationTransition( + turns: Tween(begin: 0.0, end: 25.0) + .animate(rotationOrbitController), + child: Builder(builder: (context) { + return Container( + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all( + color: secondaryColor, + width: 3, + ), + borderRadius: BorderRadius.circular(50), + ), + child: Button( + padding: const EdgeInsets.all(8), + icon: CustomIcon( + name: 'orbit', + size: 40, + color: secondaryColor), + onPressed: () async { + // Start or stop orbiting based on the current state + if (isOrbiting) { + await stopOrbit(); + } else { + await startOrbit(); + } + + // Update the state to reflect the new orbiting state + isOrbiting = !isOrbiting; + })); + }), + ), + ))) ]); } } diff --git a/pubspec.lock b/pubspec.lock index 556e488..710a5c6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" dartssh2: dependency: "direct main" description: @@ -304,30 +296,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 - url: "https://pub.dev" - source: hosted - version: "2.1.3" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d - url: "https://pub.dev" - source: hosted - version: "2.2.4" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 - url: "https://pub.dev" - source: hosted - version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -448,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + showcaseview: + dependency: "direct main" + description: + name: showcaseview + sha256: f236c1f44b286e1ba888f8701adca067af92c33e29ea937d0fe9b4a29d4cd41e + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index c7f3285..e29f8e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.6.0+1 +version: 1.0.0+1 environment: sdk: '>=3.3.0 <4.0.0' @@ -31,17 +31,13 @@ dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.6 shared_preferences: ^2.2.3 - path_provider: ^2.1.3 webview_flutter: ^4.8.0 model_viewer_plus: ^1.8.0 google_maps_flutter: ^2.7.0 http: ^1.2.1 dartssh2: ^2.8.2 + showcaseview: ^3.0.0 dev_dependencies: flutter_test: