diff --git a/Space_Mapper/lib/models/list_view.dart b/Space_Mapper/lib/models/list_view.dart new file mode 100644 index 00000000..fcf4621a --- /dev/null +++ b/Space_Mapper/lib/models/list_view.dart @@ -0,0 +1,150 @@ +import 'package:collection/collection.dart'; + +class CustomLocationsManager { + static List customLocations = []; + + static List fetchAll({required bool sortByNewest}) { + if (sortByNewest) { + return customLocations; + } else { + return new List.from(customLocations.reversed); + } + } + + static CustomLocation? fetchByUUID(String uuid) { + CustomLocation? ret = customLocations + .firstWhereOrNull((element) => element.getUUID() == uuid); + return ret; + } +} + +class CustomLocation { + late final String _uuid; + late String _locality = ""; + late String _subAdministrativeArea = ""; + late String _street = ""; + // ignore: non_constant_identifier_names + late String _ISOCountry = ""; // 2 letter code + late String _timestamp = ""; // ex: 2021-10-25T21:25:08.210Z + late String _activity = ""; + late num _speed = -1; //in meters / second + late num _speedAccuracy = -1; //in meters / second + late num _altitude = -1; //in meters + late num _altitudeAccuracy = -1; // in meters + + /// Makes timestamp readable by a human + 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; + } + + /// Checks if data is valid and then displays 3 lines with: Activity, Speed and Altitude + String displayCustomText(num maxSpeedAccuracy, num maxAltitudeAccuracy) { + String ret = ""; + + ret += " \nActivity: " + _activity; + + /// Speed has to be both valid and accurate + if (_speed != -1 && _speedAccuracy != -1) { + if (_speedAccuracy <= maxSpeedAccuracy) + ret += " \nSpeed: " + _speed.toString() + " m/s"; + } + if (_altitudeAccuracy <= maxAltitudeAccuracy) + ret += "\nAltitude: " + _altitude.toString() + " m"; + + return ret; + } + + // Variable setters + void setUUID(String uuid) { + _uuid = uuid; + } + + void setLocality(String locality) { + _locality = locality; + } + + void setSubAdministrativeArea(String subAdminArea) { + _subAdministrativeArea = subAdminArea; + } + + void setStreet(String street) { + _street = street; + } + + void setISOCountry(String iso) { + _ISOCountry = iso; + } + + void setTimestamp(String timestamp) { + _timestamp = formatTimestamp(timestamp); + } + + void setActivity(String activity) { + _activity = activity; + } + + void setSpeed(num speed, num speedAcc) { + _speed = speed; + _speedAccuracy = speedAcc; + } + + void setAltitude(num altitude, num altitudeAcc) { + _altitude = altitude; + _altitudeAccuracy = altitudeAcc; + } + + // Variable getters + String getUUID() { + return _uuid; + } + + String getLocality() { + return _locality; + } + + String getSubAdministrativeArea() { + return _subAdministrativeArea; + } + + String getStreet() { + return _street; + } + + String getISOCountryCode() { + return _ISOCountry; + } + + String getTimestamp() { + return _timestamp; + } + + String getActivity() { + return _activity; + } + + num getSpeed() { + return _speed; + } + + num getSpeedAcc() { + return _speedAccuracy; + } + + num getAltitude() { + return _altitude; + } + + num getAltitudeAcc() { + return _altitude; + } +} diff --git a/Space_Mapper/lib/ui/home_view.dart b/Space_Mapper/lib/ui/home_view.dart index d3696e1b..cbc02c6e 100644 --- a/Space_Mapper/lib/ui/home_view.dart +++ b/Space_Mapper/lib/ui/home_view.dart @@ -388,7 +388,7 @@ class HomeViewState extends State context, MaterialPageRoute(builder: (context) => FormView())); _onClickGetCurrentPosition(); }, - child: Icon(Icons.gps_fixed), + child: Icon(Icons.person), backgroundColor: Colors.blue, ), ); diff --git a/Space_Mapper/lib/ui/list_view.dart b/Space_Mapper/lib/ui/list_view.dart index 2e4b714f..611c5e48 100644 --- a/Space_Mapper/lib/ui/list_view.dart +++ b/Space_Mapper/lib/ui/list_view.dart @@ -1,91 +1,158 @@ +import '../models/list_view.dart'; 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 { +///Get data such as city, province, postal code, street name, country... +Future getLocationData(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) {} + return placemarks[0]; + } catch (err) { + return null; + } } -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']); +void createCustomLocation(var recordedLocation, Placemark? placemark) { + CustomLocation location = new CustomLocation(); + //Add location to list + CustomLocationsManager.customLocations.add(location); + + //Save data from flutter_background_geolocation library + location.setUUID(recordedLocation['uuid']); + location.setTimestamp(recordedLocation['timestamp']); + location.setActivity(recordedLocation['activity']['type']); + location.setSpeed(recordedLocation['coords']['speed'], + recordedLocation['coords']['speed_accuracy']); + location.setAltitude(recordedLocation['coords']['altitude'], + recordedLocation['coords']['altitude_accuracy']); + + //Add our custom data + if (placemark != null) { + String? locality = placemark.locality; + String? subAdminArea = placemark.subAdministrativeArea; + String? street = placemark.street; + if (street != null) street += ", ${placemark.name}"; // 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); + String? ISO = placemark.isoCountryCode; + + location.setLocality(locality!); + location.setSubAdministrativeArea(subAdminArea!); + location.setStreet(street!); + location.setISOCountry(ISO!); } - 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; +class STOListView extends StatefulWidget { + const STOListView({Key? key}) : super(key: key); + + @override + _STOListViewState createState() => _STOListViewState(); +} + +class _STOListViewState extends State { + late List customLocations = + CustomLocationsManager.fetchAll(sortByNewest: true); + + Future recalculateLocations() async { + List recordedLocations = await bg.BackgroundGeolocation.locations; + + /// We check if there are new location entries that we haven't saved in our list + if (recordedLocations.length != customLocations.length) { + for (int i = recordedLocations.length - 1; i >= 0; --i) { + for (int j = customLocations.length - 1; j >= 0; --j) { + if (recordedLocations[i]['uuid'] == + CustomLocationsManager.customLocations[j].getUUID()) continue; + } + //Match not found, we add the location + createCustomLocation( + recordedLocations[i], + await getLocationData(recordedLocations[i]['coords']['latitude'], + recordedLocations[i]['coords']['longitude'])); + + //We update the state to display the new locations + if (this + .mounted) // We check if this screen is active. If we do 'setState' while it's not active, it'll crash (throw exception) + { + setState(() { + customLocations = + CustomLocationsManager.fetchAll(sortByNewest: true); + print("Loading positions: " + + customLocations.length.toString() + + " out of " + + recordedLocations.length.toString()); + }); + } + } } - return result; } + + @override + void initState() { + super.initState(); + recalculateLocations(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("Locations History")), + body: ListView.builder( + itemCount: customLocations.length, + itemBuilder: (context, index) { + CustomLocation thisLocation = customLocations[index]; + return _tile( + thisLocation.getLocality() + + ", " + + thisLocation.getSubAdministrativeArea() + + ", " + + thisLocation.getISOCountryCode(), + thisLocation.getTimestamp() + "\n" + thisLocation.getStreet(), + thisLocation.displayCustomText(10.0, 10.0), + Icons.gps_fixed); + }, + )); + } + + ListTile _tile(String title, String subtitle, String text, IconData icon) => + ListTile( + title: Text(title, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 20, + )), + 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], + ), + ); } -class STOListView extends StatelessWidget { +/*class STOListView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text("Location History")), + appBar: AppBar(title: Text("Locations History")), body: FutureBuilder( future: buildLocationsList(), builder: (context, snapshot) { @@ -95,7 +162,9 @@ class STOListView extends StatelessWidget { } else if (snapshot.hasError) { return Text("${snapshot.error}"); } - return CircularProgressIndicator(); + return Center( + child: CircularProgressIndicator(), + ); }, )); } @@ -104,20 +173,16 @@ class STOListView extends StatelessWidget { return ListView.builder( itemCount: data.length, itemBuilder: (context, index) { - DisplayLocation thisLocation = data[index]; + CustomLocation thisLocation = data[index]; return _tile( - thisLocation.locality + + thisLocation.getUUID().toString() + + thisLocation.getLocality() + ", " + - thisLocation.subAdministrativeArea + + thisLocation.getSubAdministrativeArea() + ", " + - thisLocation.ISOCountry, - thisLocation.timestamp, - " \nActivity: " + - thisLocation.activity + - " \nSpeed: " + - thisLocation.speed.toString() + - " \nAltitude: " + - thisLocation.altitude.toString(), + thisLocation.getISOCountryCode(), + thisLocation.getTimestamp(), + thisLocation.displayCustomText(10.0, 10.0), Icons.gps_fixed); }); } @@ -150,4 +215,4 @@ class STOListView extends StatelessWidget { color: Colors.blue[500], ), ); -} +}*/ diff --git a/Space_Mapper/pubspec.lock b/Space_Mapper/pubspec.lock index cd907b31..650ebcc6 100644 --- a/Space_Mapper/pubspec.lock +++ b/Space_Mapper/pubspec.lock @@ -148,6 +148,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + faker: + dependency: "direct main" + description: + name: faker + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" ffi: dependency: transitive description: diff --git a/Space_Mapper/pubspec.yaml b/Space_Mapper/pubspec.yaml index d962915b..7ffa749a 100644 --- a/Space_Mapper/pubspec.yaml +++ b/Space_Mapper/pubspec.yaml @@ -35,7 +35,8 @@ dependencies: url_launcher: ^6.0.12 mailto: ^2.0.0 geocoding: ^2.0.1 - + faker: ^2.0.0 + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.3 diff --git a/Space_Mapper/test/unit/list_view_test.dart b/Space_Mapper/test/unit/list_view_test.dart new file mode 100644 index 00000000..fd7e0245 --- /dev/null +++ b/Space_Mapper/test/unit/list_view_test.dart @@ -0,0 +1,154 @@ +import 'package:asm/models/list_view.dart'; +import 'package:test/test.dart'; +import 'package:faker/faker.dart'; + +void main() { + group('Locations History Screen - Unit Tests', () { + group('CustomLocation class', () { + group('formatTimestamp function', () { + test('formatTimestamp: input a correct timestamp', () { + String timestamp = "2021-10-25T21:25:08.210Z"; + CustomLocation dL = new CustomLocation(); + dL.setTimestamp(timestamp); + String ret = dL.formatTimestamp(dL.getTimestamp()); + expect(ret, "2021-10-25 | 21:25:08"); + }); + }); + + group('displayCustomText function', () { + test('displayCustomText: Result when all data is valid', () { + CustomLocation location = new CustomLocation(); + location.setActivity("walking"); + location.setSpeed(5, 1); + location.setAltitude(315, 3); + + num maxSpeedAcc = 5; + num maxAltitudeAcc = 5; + + String ret = location.displayCustomText(maxSpeedAcc, maxAltitudeAcc); + expect( + ret, + equals(" \nActivity: " + + location.getActivity() + + " \nSpeed: " + + location.getSpeed().toString() + + " m/s" + + "\nAltitude: " + + location.getAltitude().toString() + + " m")); + }); + test('displayCustomText: Result when altitude in not accurate', () { + CustomLocation location = new CustomLocation(); + location.setActivity("walking"); + location.setSpeed(1, 1); + location.setAltitude(5, + 50); //It'ss higher than max allowed altitude, so it should not print altitude + + num maxSpeedAcc = 5; + num maxAltitudeAcc = 10; + + String ret = location.displayCustomText(maxSpeedAcc, maxAltitudeAcc); + expect( + ret, + equals(" \nActivity: " + + location.getActivity() + + " \nSpeed: " + + location.getSpeed().toString() + + " m/s")); + }); + test( + 'displayCustomText: Result when speed and speedAccuracy is -1 (gps not used)', + () { + CustomLocation location = new CustomLocation(); + location.setActivity("walking"); + location.setSpeed(-1, -1); + location.setAltitude(5, 10); + + num maxSpeedAcc = 5; + num maxAltitudeAcc = 10; + + String ret = location.displayCustomText(maxSpeedAcc, maxAltitudeAcc); + expect( + ret, + equals(" \nActivity: " + + location.getActivity() + + "\nAltitude: " + + location.getAltitude().toString() + + " m")); + }); + test( + 'displayCustomText: Result when both speed and altitude are invalid', + () { + CustomLocation location = new CustomLocation(); + location.setActivity("walking"); + location.setSpeed(-1, -1); //Invalid input, should not print speed + location.setAltitude(5, + 50); //Accuracy is higher than max allowed altitude, so it should not print altitude + + num maxSpeedAcc = 5; + num maxAltitudeAcc = 10; + + String ret = location.displayCustomText(maxSpeedAcc, maxAltitudeAcc); + expect(ret, equals(" \nActivity: " + location.getActivity())); + }); + }); + }); + group("CustomLocationsManager class", () { + test("fetchAll and fetchByUUID", () async { + //We create 50 fake locations to do the test + for (int i = 0; i < 50; i++) { + CustomLocation location = new CustomLocation(); + location.setUUID(faker.guid.guid()); + location.setActivity("walk"); + location.setAltitude( + faker.randomGenerator + .numbers(8848, 1)[0], //8848 metres is Mount Everest height + faker.randomGenerator.numbers(300, 1)[0]); + location.setSpeed(faker.randomGenerator.numbers(600, 1)[0], + faker.randomGenerator.numbers(300, 1)[0]); + location.setISOCountry(faker.address.countryCode()); + location.setLocality(faker.address.city()); + location.setStreet(faker.address.streetAddress()); + location.setSubAdministrativeArea(faker.address.state()); + location.setTimestamp(faker.date.random.toString()); + CustomLocationsManager.customLocations.add(location); + } + + List locations = + CustomLocationsManager.fetchAll(sortByNewest: true); + for (var location in locations) { + expect(location.getUUID(), isNotEmpty); + + CustomLocation? fetchedLocation = + CustomLocationsManager.fetchByUUID(location.getUUID()); + + expect(fetchedLocation, + isNotNull); // Not null because we get the UUID from the list, so it must exist + + if (fetchedLocation != null) { + // Test that every fetched location is exactly equal as the current location + expect(fetchedLocation.getUUID(), equals(location.getUUID())); + expect( + fetchedLocation.getActivity(), equals(location.getActivity())); + expect( + fetchedLocation.getAltitude(), equals(location.getAltitude())); + expect(fetchedLocation.getAltitudeAcc(), + equals(location.getAltitudeAcc())); + expect(fetchedLocation.getISOCountryCode(), + equals(location.getISOCountryCode())); + expect( + fetchedLocation.getLocality(), equals(location.getLocality())); + expect(fetchedLocation.getSpeed(), equals(location.getSpeed())); + expect( + fetchedLocation.getSpeedAcc(), equals(location.getSpeedAcc())); + expect(fetchedLocation.getStreet(), equals(location.getStreet())); + expect(fetchedLocation.getSubAdministrativeArea(), + equals(location.getSubAdministrativeArea())); + expect(fetchedLocation.getTimestamp(), + equals(location.getTimestamp())); + } + } + }); + }); + }); +} diff --git a/Space_Mapper/test/unit/report_an_issue_test.dart b/Space_Mapper/test/unit/report_an_issue_test.dart index 93d800b3..ff9d5c3a 100644 --- a/Space_Mapper/test/unit/report_an_issue_test.dart +++ b/Space_Mapper/test/unit/report_an_issue_test.dart @@ -1,9 +1,5 @@ -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', () {