diff --git a/Space_Mapper/lib/ui/list_view.dart b/Space_Mapper/lib/ui/list_view.dart index 2f065db6..2e4b714f 100644 --- a/Space_Mapper/lib/ui/list_view.dart +++ b/Space_Mapper/lib/ui/list_view.dart @@ -1,14 +1,93 @@ import 'package:flutter/material.dart'; import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg; +import 'package:geocoding/geocoding.dart'; + +Future getLocationData( + String dataType, double lat, double long) async { + try { + List placemarks = await placemarkFromCoordinates( + lat, + long, + ); + switch (dataType) { + case "locality": + return placemarks[0].locality; + case "subAdministrativeArea": + return placemarks[0].subAdministrativeArea; + case "ISOCountry": + return placemarks[0].isoCountryCode; + default: + return ""; + } + } catch (err) {} +} + +Future>? buildLocationsList() async { + List locations = await bg.BackgroundGeolocation.locations; + List ret = []; + + for (int i = 0; i < locations.length; ++i) { + String? locality = await getLocationData( + "locality", + locations[i]['coords']['latitude'], + locations[i]['coords']['longitude']); + String? administrativeArea = await getLocationData( + "subAdministrativeArea", + locations[i]['coords']['latitude'], + locations[i]['coords']['longitude']); + // ignore: non_constant_identifier_names + String? ISOCountry = await getLocationData( + "ISOCountry", + locations[i]['coords']['latitude'], + locations[i]['coords']['longitude']); + String timestamp = locations[i]['timestamp']; + String activity = locations[i]['activity']['type']; + num speed = locations[i]['coords']['speed']; + num altitude = locations[i]['coords']['altitude']; + var add = new DisplayLocation(locality!, administrativeArea!, ISOCountry!, + timestamp, activity, speed, altitude); + ret.add(add); + } + return ret; +} + +class DisplayLocation { + DisplayLocation(this.locality, this.subAdministrativeArea, this.ISOCountry, + timestamp, this.activity, this.speed, this.altitude) { + this.timestamp = formatTimestamp(timestamp); + } + late String locality; + late String subAdministrativeArea; + // ignore: non_constant_identifier_names + late String ISOCountry; + late String timestamp; + late String activity; + late num speed; + late num altitude; + + String formatTimestamp(String timestamp) { + //2021-10-25T21:25:08.210Z <- This is the original format + //2021-10-25 | 21:25:08 <- This is the result + String result = ""; + for (int i = 0; i < timestamp.length; ++i) { + if (timestamp[i] != "T" && timestamp[i] != ".") + result += timestamp[i]; + else if (timestamp[i] == "T") + result += " | "; + else if (timestamp[i] == ".") break; + } + return result; + } +} class STOListView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text("Location List")), + appBar: AppBar(title: Text("Location History")), body: FutureBuilder( - future: bg.BackgroundGeolocation.locations, + future: buildLocationsList(), builder: (context, snapshot) { if (snapshot.hasData) { List? data = snapshot.data; @@ -25,23 +104,47 @@ class STOListView extends StatelessWidget { return ListView.builder( itemCount: data.length, itemBuilder: (context, index) { - var thisLocation = data[index]; + DisplayLocation thisLocation = data[index]; return _tile( - thisLocation['timestamp'] + - " activity: " + - thisLocation['activity']['type'].toString(), - thisLocation['coords'].toString(), + thisLocation.locality + + ", " + + thisLocation.subAdministrativeArea + + ", " + + thisLocation.ISOCountry, + thisLocation.timestamp, + " \nActivity: " + + thisLocation.activity + + " \nSpeed: " + + thisLocation.speed.toString() + + " \nAltitude: " + + thisLocation.altitude.toString(), Icons.gps_fixed); }); } - ListTile _tile(String title, String subtitle, IconData icon) => ListTile( + ListTile _tile(String title, String subtitle, String text, IconData icon) => + ListTile( title: Text(title, style: TextStyle( fontWeight: FontWeight.w500, fontSize: 20, )), - subtitle: Text(subtitle), + subtitle: new RichText( + text: new TextSpan( + // Note: Styles for TextSpans must be explicitly defined. + // Child text spans will inherit styles from parent + style: new TextStyle( + fontSize: 14.0, + color: Colors.black, + ), + children: [ + new TextSpan( + text: subtitle, + style: new TextStyle(fontWeight: FontWeight.bold)), + new TextSpan(text: text), + ], + ), + ), leading: Icon( icon, color: Colors.blue[500], diff --git a/Space_Mapper/lib/ui/report_an_issue.dart b/Space_Mapper/lib/ui/report_an_issue.dart new file mode 100644 index 00000000..7df3e856 --- /dev/null +++ b/Space_Mapper/lib/ui/report_an_issue.dart @@ -0,0 +1,159 @@ +import 'package:asm/ui_style/report_an_issue_style.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_vector_icons/flutter_vector_icons.dart'; +import 'package:mailto/mailto.dart'; + +class ReportAnIssue extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Report an Issue")), + body: reportIssueBody(context), + ); + } +} + +Widget reportIssueBody(BuildContext context) { + List emails = ['john.palmer@upf.edu', 'pablogalve100@gmail.com']; + + return Padding( + padding: EdgeInsets.all(ReportAnIssueStyle.screenPadding), + child: Column( + children: [ + Text( + "Help us improve by either reporting an issue or requesting a useful feature.", + style: TextStyle(fontSize: ReportAnIssueStyle.normalTextSize), + ), + displayService( + "Github", + Icon( + AntDesign.github, + size: ReportAnIssueStyle.iconSize, + )), + Container( + margin: EdgeInsets.only( + bottom: ReportAnIssueStyle.marginBetweenTextAndButtons), + child: Text( + "Report issues on github to get the fastest solution.", + style: TextStyle(fontSize: ReportAnIssueStyle.normalTextSize), + ), + ), + customButtonWithUrl( + "Go to Github Issues", + "https://github.com/ActivitySpaceProject/space_mapper/issues", + ReportAnIssueStyle.requestFeatureColor, + context), + Container( + //Container only to add more margin + margin: EdgeInsets.only( + bottom: ReportAnIssueStyle.marginBetweenTextAndButtons), + ), + displayService( + "Email", + Icon( + Icons.email_outlined, + size: ReportAnIssueStyle.iconSize, + )), + Container( + margin: EdgeInsets.only( + bottom: ReportAnIssueStyle.marginBetweenTextAndButtons), + child: Text( + "As an alternative, you can send us an email.", + style: TextStyle(fontSize: ReportAnIssueStyle.normalTextSize), + )), + customButtonWithUrl("Report an issue by email", null, + ReportAnIssueStyle.reportIssueColor, context, + emails: emails, + subject: 'Space Mapper: Report Issue', + body: + 'Dear Space Mapper support, \n\n I want to report the following issue:'), + customButtonWithUrl("Request a feature by email", null, + ReportAnIssueStyle.requestFeatureColor, context, + emails: emails, + subject: 'Space Mapper: Feature Request', + body: + 'Dear Space Mapper support, \n\n I want to request the following feature:'), + ], + )); +} + +_launchUrl(String url) async { + //The url must be valid + + if (await canLaunch(url)) { + await launch(url); + } else { + throw 'Could not launch $url'; + } +} + +Future launchMailto( + List emails, String? subject, String? body) async { + //All emails must be valid + for (int i = 0; i < emails.length; i++) { + if (RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+") + .hasMatch(emails[i]) == + false) { + return false; + } + } + + final mailtoLink = Mailto( + to: emails, + subject: subject, + body: body, + ); + // Convert the Mailto instance into a string. + // Use either Dart's string interpolation + // or the toString() method. + await launch('$mailtoLink'); + return true; +} + +Widget displayService(String name, Icon icon) { + return Container( + margin: EdgeInsets.fromLTRB(0.0, ReportAnIssueStyle.marginIconTopAndBottom, + 0.0, ReportAnIssueStyle.marginIconTopAndBottom), + child: Row( + children: [ + icon, + Container( + margin: EdgeInsets.only( + right: ReportAnIssueStyle.marginBetweenIconAndTitle), + ), + Text( + name, + style: TextStyle(fontSize: ReportAnIssueStyle.titleSize), + ), + ], + ), + ); +} + +Widget customButtonWithUrl(String text, String? openUrl, + MaterialStateProperty backgroundColor, BuildContext context, + {List? emails, String? subject, String? body}) { + return Container( + width: MediaQuery.of(context).size.width * + ReportAnIssueStyle.buttonWidthPercentage, + child: TextButton( + style: ButtonStyle( + backgroundColor: backgroundColor, + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + ReportAnIssueStyle.buttonBorderRadius), + side: BorderSide(color: Colors.black)))), + onPressed: () { + //If emails list is null, this buttons opens a link on click, otherwise it sends an email with introduced data + emails == null + ? _launchUrl(openUrl!) + : launchMailto(emails, subject!, body!); + }, + child: Text( + text, + style: TextStyle(color: Colors.black), + ), + )); +} diff --git a/Space_Mapper/lib/ui/side_drawer.dart b/Space_Mapper/lib/ui/side_drawer.dart index 95481ea5..4b0b6377 100644 --- a/Space_Mapper/lib/ui/side_drawer.dart +++ b/Space_Mapper/lib/ui/side_drawer.dart @@ -1,4 +1,5 @@ import 'package:asm/ui/list_view.dart'; +import 'package:asm/ui/report_an_issue.dart'; import 'package:asm/ui/web_view.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -7,6 +8,13 @@ import 'package:flutter_background_geolocation/flutter_background_geolocation.da as bg; class SpaceMapperSideDrawer extends StatelessWidget { + _shareLocations() async { + var now = new DateTime.now(); + List allLocations = await bg.BackgroundGeolocation.locations; + Share.share(allLocations.toString(), + subject: "space_mapper_trajectory_" + now.toIso8601String() + ".json"); + } + _launchProjectURL() async { const url = 'http://activityspaceproject.com/'; if (await canLaunch(url)) { @@ -16,13 +24,6 @@ class SpaceMapperSideDrawer extends StatelessWidget { } } - _shareLocations() async { - var now = new DateTime.now(); - List allLocations = await bg.BackgroundGeolocation.locations; - Share.share(allLocations.toString(), - subject: "space_mapper_trajectory_" + now.toIso8601String() + ".json"); - } - @override Widget build(BuildContext context) { return new Drawer( @@ -51,7 +52,7 @@ class SpaceMapperSideDrawer extends StatelessWidget { Card( child: ListTile( leading: const Icon(Icons.list), - title: Text('List Locations'), + title: Text('Locations History'), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => STOListView())); @@ -75,6 +76,16 @@ class SpaceMapperSideDrawer extends StatelessWidget { _launchProjectURL(); }, ), + ), + Card( + child: ListTile( + leading: const Icon(Icons.report_problem_outlined), + title: Text('Report an Issue'), + onTap: () { + Navigator.push(context, + MaterialPageRoute(builder: (context) => ReportAnIssue())); + }, + ), ) ], ), diff --git a/Space_Mapper/lib/ui/web_view.dart b/Space_Mapper/lib/ui/web_view.dart index c70f9a46..0667d40f 100644 --- a/Space_Mapper/lib/ui/web_view.dart +++ b/Space_Mapper/lib/ui/web_view.dart @@ -3,21 +3,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; -const String kNavigationExamplePage = ''' - -Navigation Delegate Example - -

-The navigation delegate is set to block navigation to the youtube website. -

- - - -'''; - class MyWebView extends StatefulWidget { @override _MyWebViewState createState() => _MyWebViewState(); @@ -47,7 +32,9 @@ class _MyWebViewState extends State { // to allow calling Scaffold.of(context) so we can show a snackbar. body: Builder(builder: (BuildContext context) { return WebView( - initialUrl: 'https://ee.kobotoolbox.org/single/asCwpCjZ', + //initialUrl: 'https://ee.kobotoolbox.org/single/asCwpCjZ', //Original form for beta-testing + initialUrl: + 'https://ee.kobotoolbox.org/x/8528dfMs', //Form to upload a json file javascriptMode: JavascriptMode.unrestricted, onWebViewCreated: (WebViewController webViewController) { _controller.complete(webViewController); @@ -58,14 +45,6 @@ class _MyWebViewState extends State { javascriptChannels: { _toasterJavascriptChannel(context), }, - navigationDelegate: (NavigationRequest request) { - if (request.url.startsWith('https://www.youtube.com/')) { - print('blocking navigation to $request}'); - return NavigationDecision.prevent; - } - print('allowing navigation to $request'); - return NavigationDecision.navigate; - }, onPageStarted: (String url) { print('Page started loading: $url'); }, diff --git a/Space_Mapper/lib/ui_style/report_an_issue_style.dart b/Space_Mapper/lib/ui_style/report_an_issue_style.dart new file mode 100644 index 00000000..39113690 --- /dev/null +++ b/Space_Mapper/lib/ui_style/report_an_issue_style.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class ReportAnIssueStyle { + //General + static const screenPadding = 20.0; + static const iconSize = 100.0; + + //Font sizes + static const titleSize = 25.0; + static const normalTextSize = 17.0; + + //Margins + static const marginBetweenTextAndButtons = 10.0; + static const marginIconTopAndBottom = 20.0; + static const marginBetweenIconAndTitle = 25.0; + + //Buttons + static const buttonBorderRadius = 18.0; + static MaterialStateProperty reportIssueColor = + MaterialStateProperty.all(Colors.red[100]); + static MaterialStateProperty requestFeatureColor = + MaterialStateProperty.all(Colors.lightBlue[100]); + static const buttonWidthPercentage = 0.6; //Double from 0 to 1 +} diff --git a/Space_Mapper/pubspec.lock b/Space_Mapper/pubspec.lock index 960992e5..cd907b31 100644 --- a/Space_Mapper/pubspec.lock +++ b/Space_Mapper/pubspec.lock @@ -205,6 +205,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_vector_icons: + dependency: "direct main" + description: + name: flutter_vector_icons + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -217,6 +224,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + geocoding: + dependency: "direct main" + description: + name: geocoding + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" glob: dependency: transitive description: @@ -287,6 +308,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + mailto: + dependency: "direct main" + description: + name: mailto + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" matcher: dependency: transitive description: diff --git a/Space_Mapper/pubspec.yaml b/Space_Mapper/pubspec.yaml index f15305c3..d962915b 100644 --- a/Space_Mapper/pubspec.yaml +++ b/Space_Mapper/pubspec.yaml @@ -21,8 +21,7 @@ dependencies: sdk: flutter flutter_background_geolocation: ^4.3.0 shared_preferences: ^2.0.8 - background_fetch: ^1.0.1 - url_launcher: ^6.0.12 + background_fetch: ^1.0.1 http: ^0.13.4 flutter_map: ^0.14.0 # flutter_webview_plugin: ^0.4.0 @@ -33,11 +32,15 @@ dependencies: uuid: 3.0.5 flutter_form_builder: ^6.1.0+1 latlong2: ^0.8.1 - + url_launcher: ^6.0.12 + mailto: ^2.0.0 + geocoding: ^2.0.1 + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.3 - + flutter_vector_icons: ^1.0.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/Space_Mapper/test/integration/app_test.dart b/Space_Mapper/test/integration/app_test.dart deleted file mode 100644 index 573fd058..00000000 --- a/Space_Mapper/test/integration/app_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:asm/main.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -Widget makeTestableWidget() => MaterialApp(home: Image.network('')); - -void main() { - testWidgets('MyApp() has an appName that equals Space Mapper', - (WidgetTester tester) async { - // Test code goes here. - await tester.pumpWidget(MyApp()); - - // Create the Finders. - final titleFinder = find.text("Space Mapper"); - - // Use the `findsOneWidget` matcher provided by flutter_test to - // verify that the Text widgets appear exactly once in the widget tree. - expect(titleFinder, findsOneWidget); - }); -} diff --git a/Space_Mapper/test/unit/report_an_issue_test.dart b/Space_Mapper/test/unit/report_an_issue_test.dart new file mode 100644 index 00000000..93d800b3 --- /dev/null +++ b/Space_Mapper/test/unit/report_an_issue_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import '../../lib/ui/report_an_issue.dart'; +import 'package:test/test.dart'; +import 'package:mockito/mockito.dart'; + +class MockBuildContext extends Mock implements BuildContext {} + +void main() { + group('Report an Issue Screen - Unit Tests', () { + group('launchMailto', () { + test('launchMailto: Return false if there is at least on invalid email', + () { + List emails = [ + 'test@test.com', + 'test2@gmail.com', + 'thisfails.com', //This email will make the function return false + ]; + Future ret = launchMailto(emails, '', ''); + expect(ret, completion(equals(false))); + }); + }); + }); +} diff --git a/Space_Mapper/test/unit/shared_preferences_test.dart b/Space_Mapper/test/unit/shared_preferences_test.dart deleted file mode 100644 index 49d4f81f..00000000 --- a/Space_Mapper/test/unit/shared_preferences_test.dart +++ /dev/null @@ -1,9 +0,0 @@ -//import 'package:shared_preferences/shared_preferences.dart'; -//import 'package:test/test.dart'; - -void main() { - /*test('SharedPreferences.getInstance()', () { - final _prefs = SharedPreferences.getInstance(); - expect(_prefs, isNotNull); - });*/ -} diff --git a/Space_Mapper/test/unit/spacemapper_auth_test.dart b/Space_Mapper/test/unit/spacemapper_auth_test.dart deleted file mode 100644 index 45804aa8..00000000 --- a/Space_Mapper/test/unit/spacemapper_auth_test.dart +++ /dev/null @@ -1,9 +0,0 @@ -//import 'package:test/test.dart'; -//import '../../lib/util/spacemapper_auth.dart'; - -void main() { - /*test('TransistorAuth.register()', () { - final user = TransistorAuth.register(); - expect(user, isNotNull); - });*/ -}