diff --git a/.gitignore b/.gitignore index c13c084..dca3b69 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ pubspec.lock # Directory created by dartdoc # If you don't generate documentation locally you can remove this line. doc/api/ + +assets/private-settings.json +android/app/src/main/AndroidManifest.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest-Sample.xml similarity index 51% rename from android/app/src/main/AndroidManifest.xml rename to android/app/src/main/AndroidManifest-Sample.xml index 425bbec..b5d30e4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest-Sample.xml @@ -1,11 +1,13 @@ + package="org.comunes.firesflutter"> + + + android:name="io.flutter.app.FlutterApplication" + android:label="All Against The Fire!" + android:icon="@mipmap/launch_image"> + + + android:name=".MainActivity" + android:launchMode="singleTop" + android:theme="@style/LaunchTheme" + android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density" + android:hardwareAccelerated="true" + android:windowSoftInputMode="adjustResize"> + android:name="io.flutter.app.android.SplashScreenUntilFirstFrame" + android:value="true"/> + diff --git a/android/build.gradle b/android/build.gradle index 4476887..b7d7695 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,6 +6,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:3.0.1' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.1.2-4' } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index cdde493..03878f6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -41,5 +41,9 @@ UIViewControllerBasedStatusBarAppearance + NSLocationWhenInUseUsageDescription + Your location will be used to center our map and display fires near you + NSLocationAlwaysUsageDescription + Your location will be used notify fires near you diff --git a/lib/activeFires.dart b/lib/activeFires.dart index a871105..99d74cf 100644 --- a/lib/activeFires.dart +++ b/lib/activeFires.dart @@ -1,34 +1,244 @@ import 'package:flutter/material.dart'; +import 'package:location/location.dart'; import 'mainDrawer.dart'; +import 'genericMap.dart'; +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:comunes_flutter/comunes_flutter.dart'; +import 'colors.dart'; +import 'placesAutocompleteUtils.dart'; +import 'basicLocation.dart'; +import 'package:collection/collection.dart' show lowerBound; -class ActiveFiresMap extends StatelessWidget { - static const String routeName = '/fires'; +class _ActiveFiresPageState extends State { final GlobalKey _scaffoldKey = new GlobalKey(); + // https://docs.flutter.io/flutter/dart-core/List-class.html + final List _saved = []; + int length; + + _ActiveFiresPageState(); + + Widget _buildRow(BasicLocation loc) { + String desc = loc.description != null ? loc.description: + 'Position: ${loc.lat}, ${loc.lon}'; + return new ListTile( + dense: false, + leading: const Icon(Icons.location_on), + // trailing: const Icon(Icons.delete), + title: new Text(desc), + onTap: () { + Navigator.push(context, + new MaterialPageRoute(builder: (context) => new GenericMap(title: desc, latitude: loc.lat, longitude: loc.lon))); + }); + } + + Widget _buildSavedLocations() { + return new ListView.builder( + padding: const EdgeInsets.all(16.0), + itemCount: _saved.length, + itemBuilder: (BuildContext _context, int i) { + // Add a one-pixel-high divider widget before each row + // in the ListView. + /* if (i.isOdd) { + return new Divider(); + } */ + + // The syntax "i ~/ 2" divides i by 2 and returns an + // integer result. + // For example: 1, 2, 3, 4, 5 becomes 0, 1, 1, 2, 2. + // This calculates the actual number of saved items + // in the ListView, minus the divider widgets. + // final int index = i ~/ 2; + // print('$i $index'); + // if (index >= _saved.length) return null; + return _buildItem(_saved.elementAt(i)); + }); + } + + void handleUndo(BasicLocation item) { + print('Undo $item'); + final int insertionIndex = lowerBound(_saved, item); + setState(() { + _saved.insert(insertionIndex, item); + }); + } + + Widget _buildItem(BasicLocation item) { + final ThemeData theme = Theme.of(context); + return new Dismissible( + key: new ObjectKey(item), + direction: DismissDirection.horizontal, + onDismissed: (DismissDirection direction) { + setState(() { + _saved.remove(item); + }); + final String action = 'deleted'; + _scaffoldKey.currentState.showSnackBar(new SnackBar( + content: new Text('You $action item'), // ${item.index}'), + action: new SnackBarAction( + label: 'UNDO', + onPressed: () { + handleUndo(item); + }))); + }, + background: new Container( + color: theme.primaryColor, + child: const ListTile( + leading: + const Icon(Icons.delete, color: Colors.white, size: 36.0))), + secondaryBackground: new Container( + color: theme.primaryColor, + child: const ListTile( + trailing: + const Icon(Icons.delete, color: Colors.white, size: 36.0))), + child: new Container( + decoration: new BoxDecoration( + color: theme.canvasColor, + border: new Border( + bottom: new BorderSide(color: theme.dividerColor))), + child: _buildRow(item))); + } + + Future getUserLocation() async { + // FIXME do something with this + + String error; + // Platform messages may fail, so we use a try/catch PlatformException. + try { + Location _location = new Location(); + Map location = await _location.getLocation; + error = null; + print('location $location'); + + // It seems that the lib fails with lat/lon values + return new BasicLocation( + lon: location['latitude'], lat: location['longitude']); + } on PlatformException catch (e) { + if (e.code == 'PERMISSION_DENIED') { + error = 'Permission denied'; + } else if (e.code == 'PERMISSION_DENIED_NEVER_ASK') { + error = + 'Permission denied - please ask the user to enable it from the app settings'; + } + // FIXME + throw e; + } + } + + @override + void initState() { + super.initState(); + length = _saved.length; + } @override Widget build(BuildContext context) { + print('Building Active Fires, saved $length'); + final title = 'Active fires locations'; return Scaffold( - key: _scaffoldKey, - drawer: new MainDrawer(context), - appBar: new AppBar( - title: Text('Active fires'), - leading: IconButton( - icon: Icon(Icons.menu), + key: _scaffoldKey, + drawer: new MainDrawer(context), + appBar: new AppBar( + title: Text(title), + leading: IconButton( + icon: Icon(Icons.menu), + onPressed: () { + _scaffoldKey.currentState.openDrawer(); + }, + ), + /* actions: [ + new IconButton( + icon: Icon(Icons.location_searching), onPressed: () {}), + IconButton( + icon: Icon(Icons.more_vert), onPressed: () { - _scaffoldKey.currentState.openDrawer(); + print('More button'); }, ), - actions: [ - new IconButton( - icon: Icon(Icons.location_searching), onPressed: () => {}), - IconButton( - icon: Icon(Icons.more_vert), - onPressed: () { - print('More button'); - }, - ), - ], - ), - body: new Text('No active fires')); + ], */ + ), + body: length > 0 + ? _buildSavedLocations() + : new Center( + child: new CenteredColumn(children: [ + new RoundedBtn( + icon: Icons.location_searching, + text: 'Add your position', + onPressed: onAddYourLocation, + backColor: fires600), + const SizedBox(height: 26.0), + new RoundedBtn( + icon: Icons.edit_location, + text: 'Add some other place', + onPressed: () { + onAddOtherLocation(context); + }, + backColor: fires600), + ])), + floatingActionButton: length > 0 + ? Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + FloatingActionButton.extended( + onPressed: onAddYourLocation, + heroTag: 'yourposition', + label: const Text('Add your position'), + icon: const Icon(Icons.location_searching), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: FloatingActionButton.extended( + onPressed: () { + onAddOtherLocation(context); + }, + heroTag: 'otherplace', + label: new Text('Add some other place'), + icon: const Icon(Icons.edit_location), + ), + ) + ]) + : null, + ); + } + + void onAddYourLocation() { + Future location = getUserLocation(); + _saveLocation(location); + } + + void onAddOtherLocation(BuildContext context) { + Future location = openPlacesDialog(context); + _saveLocation(location); + } + + void _saveLocation(Future location) { + location.then((newLocation) { + this.setState(() { + _saved.add(newLocation); + length = _saved.length; + }); + }); } } + +class MyDrawer extends StatelessWidget { + static final MyDrawer _drawer = new MyDrawer._internal(); + + factory MyDrawer() { + return _drawer; + } + + MyDrawer._internal(); + + @override + Widget build(BuildContext context) { + return new Text("Drawer"); + } +} + +class ActiveFiresPage extends StatefulWidget { + static const String routeName = '/fires'; + + ActiveFiresPage(); + + @override + _ActiveFiresPageState createState() => _ActiveFiresPageState(); +} diff --git a/lib/basicLocation.dart b/lib/basicLocation.dart new file mode 100644 index 0000000..4f04154 --- /dev/null +++ b/lib/basicLocation.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +class BasicLocation { + final double lat; + final double lon; + final String description; + + BasicLocation({@required this.lat, @required this.lon, this.description}); +} diff --git a/lib/firesApp.dart b/lib/firesApp.dart index 3aed60a..2fca976 100644 --- a/lib/firesApp.dart +++ b/lib/firesApp.dart @@ -5,16 +5,26 @@ import 'theme.dart'; import 'introPage.dart'; import 'sandbox.dart'; import 'activeFires.dart'; +import 'placesAutocompleteWidget.dart'; +import 'globals.dart' as globals; class FiresApp extends StatelessWidget { - final Map routes = { - Sandbox.routeName: (BuildContext context) => new Sandbox(), - ActiveFiresMap.routeName: (BuildContext context) => new ActiveFiresMap(), - IntroPage.routeName: (BuildContext context) => new IntroPage(), - }; + + + Map routes = { + PlacesAutocompleteWidget.routeName: (BuildContext context) => + new PlacesAutocompleteWidget(), + Sandbox.routeName: (BuildContext context) => new Sandbox(), + ActiveFiresPage.routeName: (BuildContext context) => + new ActiveFiresPage(), + IntroPage.routeName: (BuildContext context) => new IntroPage(), + }; final WidgetBuilder introWidget = (context) => new IntroPage(); final WidgetBuilder continueWidget = (context) => new HomePage(); + // globals.getIt.registerSingleton + FiresApp(); + @override Widget build(BuildContext context) { return new MaterialApp( diff --git a/lib/genericMap.dart b/lib/genericMap.dart new file mode 100644 index 0000000..eefb53e --- /dev/null +++ b/lib/genericMap.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:map_view/map_view.dart'; +import 'globals.dart' as globals; + +class GenericMap extends StatefulWidget { + final String title; + final double latitude; + final double longitude; + + GenericMap( + {@required this.title, + @required this.latitude, + @required this.longitude}); + + @override + _GenericMapState createState() => new _GenericMapState( + title: this.title, latitude: this.latitude, longitude: this.longitude); +} + +class _GenericMapState extends State { + MapView mapView = new MapView(); + CameraPosition cameraPosition; + var compositeSubscription = new CompositeSubscription(); + var staticMapProvider; + Uri staticMapUri; + final String title; + Location location; + + _GenericMapState( + {@required this.title, @required latitude, @required longitude}) { + MapView.setApiKey(globals.gmapKey); + this.staticMapProvider = new StaticMapProvider(globals.gmapKey); + this.location = new Location(latitude, longitude); + } + + List _markers = [ + new Marker("1", "Work", 45.523970, -122.663081, color: Colors.blue), + new Marker("2", "Nossa Familia Coffee", 45.528788, -122.684633), + ]; + + @override + initState() { + super.initState(); + cameraPosition = new CameraPosition(location, 2.0); + staticMapUri = staticMapProvider.getStaticUri(location, 12, + width: 900, height: 400, mapType: StaticMapViewType.roadmap); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: new AppBar( + title: new Text(title), + ), + body: new Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + new Container( + height: 250.0, + child: new Stack( + children: [ + new InkWell( + child: new Center( + child: new Image.network(staticMapUri.toString()), + ), + onTap: showMap, + ) + ], + ), + ), + new Container( + padding: new EdgeInsets.only(top: 10.0), + child: new Text( + "Tap the map to interact", + style: new TextStyle(fontWeight: FontWeight.bold), + ), + ), + ])); + } + + showMap() { + mapView.show( + new MapOptions( + mapViewType: MapViewType.normal, + showUserLocation: true, + initialCameraPosition: new CameraPosition(new Location(45.5235258, -122.6732493), 14.0), + title: this.title), + toolbarActions: [new ToolbarAction("Close", 1)]); + + var sub = mapView.onMapReady.listen((_) { + mapView.setMarkers(_markers); + mapView.addMarker(new Marker("3", "10 Barrel", 45.5259467, -122.687747, + color: Colors.purple)); + mapView.zoomToFit(padding: 100); + }); + compositeSubscription.add(sub); + + sub = mapView.onLocationUpdated + .listen((location) => print("Location updated $location")); + compositeSubscription.add(sub); + + sub = mapView.onTouchAnnotation + .listen((annotation) => print("annotation tapped")); + compositeSubscription.add(sub); + + sub = mapView.onMapTapped + .listen((location) => print("Touched location $location")); + compositeSubscription.add(sub); + + sub = mapView.onCameraChanged.listen((cameraPosition) => + this.setState(() => this.cameraPosition = cameraPosition)); + compositeSubscription.add(sub); + + sub = mapView.onToolbarAction.listen((id) { + if (id == 1) { + _handleDismiss(); + } + }); + compositeSubscription.add(sub); + + sub = mapView.onInfoWindowTapped.listen((marker) { + print("Info Window Tapped for ${marker.title}"); + }); + compositeSubscription.add(sub); + } + + _handleDismiss() async { + double zoomLevel = await mapView.zoomLevel; + Location centerLocation = await mapView.centerLocation; + List visibleAnnotations = await mapView.visibleAnnotations; + print("Zoom Level: $zoomLevel"); + print("Center: $centerLocation"); + print("Visible Annotation Count: ${visibleAnnotations.length}"); + var uri = await staticMapProvider.getImageUriFromMap(mapView, + width: 900, height: 400); + setState(() => staticMapUri = uri); + mapView.dismiss(); + compositeSubscription.cancel(); + } +} + +class CompositeSubscription { + Set _subscriptions = new Set(); + + void cancel() { + for (var n in this._subscriptions) { + n.cancel(); + } + this._subscriptions = new Set(); + } + + void add(StreamSubscription subscription) { + this._subscriptions.add(subscription); + } + + void addAll(Iterable subs) { + _subscriptions.addAll(subs); + } + + bool remove(StreamSubscription subscription) { + return this._subscriptions.remove(subscription); + } + + bool contains(StreamSubscription subscription) { + return this._subscriptions.contains(subscription); + } + + List toList() { + return this._subscriptions.toList(); + } +} diff --git a/lib/globals.dart b/lib/globals.dart new file mode 100644 index 0000000..6ce67a5 --- /dev/null +++ b/lib/globals.dart @@ -0,0 +1,6 @@ +library fires.globals; +import 'package:get_it/get_it.dart'; + +String gmapKey; + +final GetIt getIt = new GetIt(); \ No newline at end of file diff --git a/lib/homePage.dart b/lib/homePage.dart index 7fa8905..6e3e5f2 100644 --- a/lib/homePage.dart +++ b/lib/homePage.dart @@ -33,12 +33,12 @@ class HomePage extends StatelessWidget { heightFactor: 0.9, child: new CenteredColumn( children: [ - customBtn( - Icons.whatshot, - "Active fires", - context, - // const EdgeInsets.only(left: 60.0), - ActiveFiresMap.routeName), + new RoundedBtn.nav( + icon: Icons.whatshot, + text: 'Active fires', + context: context, + route: ActiveFiresPage.routeName, + backColor: fires600), ], ))) ])), diff --git a/lib/main.dart b/lib/main.dart index 20e7a70..33c03b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,18 @@ import 'package:flutter/material.dart'; import 'firesApp.dart'; +import 'package:comunes_flutter/comunes_flutter.dart'; +import 'dart:async'; +import 'globals.dart' as globals; -void main() => runApp(new FiresApp()); +Future getGMapKey() async { + Secret secret = await SecretLoader(secretPath: 'assets/private-settings.json') + .load('gmap_key'); + return secret.apiKey; +} + +void main() { + getGMapKey().then((String gmapKey) { + globals.gmapKey = gmapKey; + runApp(new FiresApp()); + }); +} diff --git a/lib/mainDrawer.dart b/lib/mainDrawer.dart index f64f5b3..87fe116 100644 --- a/lib/mainDrawer.dart +++ b/lib/mainDrawer.dart @@ -4,6 +4,7 @@ import 'colors.dart'; import 'sandbox.dart'; import 'introPage.dart'; import 'activeFires.dart'; +import 'placesAutocompleteWidget.dart'; class MainDrawer extends Drawer { MainDrawer(BuildContext context, {key}) @@ -60,7 +61,7 @@ Widget mainDrawer(BuildContext context) { leading: const Icon(Icons.whatshot), title: new Text('Active fires'), onTap: () { - Navigator.pushNamed(context, ActiveFiresMap.routeName); + Navigator.pushNamed(context, ActiveFiresPage.routeName); }, ), new ListTile( @@ -71,6 +72,14 @@ Widget mainDrawer(BuildContext context) { Navigator.pushNamed(context, '/subscriptions'); }, ), + new ListTile( + leading: const Icon(Icons.location_on), + title: new Text('Places'), + onTap: () { + // Then close the drawer + Navigator.pushNamed(context, PlacesAutocompleteWidget.routeName); + }, + ), new Divider(), new ListTile( leading: const Icon(Icons.bug_report), diff --git a/lib/placesAutocompleteUtils.dart b/lib/placesAutocompleteUtils.dart new file mode 100644 index 0000000..7044703 --- /dev/null +++ b/lib/placesAutocompleteUtils.dart @@ -0,0 +1,32 @@ +import 'package:flutter_google_places_autocomplete/flutter_google_places_autocomplete.dart'; +import 'package:flutter/material.dart'; +import 'dart:async'; +import 'basicLocation.dart'; +import 'globals.dart' as globals; + +Future openPlacesDialog(BuildContext context) async { + Mode _mode = Mode.overlay; + GoogleMapsPlaces _places = new GoogleMapsPlaces(globals.gmapKey); + Prediction p = await showGooglePlacesAutocomplete( + context: context, + hint: 'Type the name of a place, region, etc', + apiKey: globals.gmapKey, + onError: (res) { + /* homeScaffoldKey.currentState.showSnackBar( + new SnackBar(content: new Text(res.errorMessage))); */ + print('Error $res'); + }, + mode: _mode, + // FIXME + language: "es", + components: [new Component(Component.country, "es")]); + if (p != null) { + // get detail (lat/lng) + PlacesDetailsResponse detail = await _places.getDetailsByPlaceId(p.placeId); + final lat = detail.result.geometry.location.lat; + final lng = detail.result.geometry.location.lng; + + return new BasicLocation(lat: lat, lon: lng, description: p.description); + } + return null; +} diff --git a/lib/placesAutocompleteWidget.dart b/lib/placesAutocompleteWidget.dart new file mode 100644 index 0000000..043e932 --- /dev/null +++ b/lib/placesAutocompleteWidget.dart @@ -0,0 +1,75 @@ +import 'package:flutter_google_places_autocomplete/flutter_google_places_autocomplete.dart'; +import 'package:flutter/material.dart'; +import 'dart:async'; +import 'globals.dart' as globals; + +class PlacesAutocompleteWidget extends StatefulWidget { + static const String routeName = '/place'; + + PlacesAutocompleteWidget(); + + @override + _PlacesAutocompleteWidgetState createState() => + _PlacesAutocompleteWidgetState(); +} + +// to get places detail (lat/lng) + +final homeScaffoldKey = new GlobalKey(); +final searchScaffoldKey = new GlobalKey(); + +GoogleMapsPlaces _places; + +class _PlacesAutocompleteWidgetState extends State { + Mode _mode = Mode.overlay; + + _PlacesAutocompleteWidgetState() { + _places = new GoogleMapsPlaces(globals.gmapKey); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + key: homeScaffoldKey, + appBar: new AppBar( + title: new Text("My App"), + ), + body: + + new RaisedButton.icon( + icon: const Icon(Icons.location_searching), + label: new Text('Write a location'), + onPressed: () async { + // show input autocomplete with selected mode + // then get the Prediction selected + Prediction p = await showGooglePlacesAutocomplete( + context: context, + hint: 'Type the name of place, region, etc', + apiKey: globals.gmapKey, + onError: (res) { + homeScaffoldKey.currentState.showSnackBar( + new SnackBar(content: new Text(res.errorMessage))); + }, + mode: _mode, + // FIXME + language: "es", + components: [new Component(Component.country, "es")]); + displayPrediction(p, homeScaffoldKey.currentState); + }, + ) + + ); + } +} + +Future displayPrediction(Prediction p, ScaffoldState scaffold) async { + if (p != null) { + // get detail (lat/lng) + PlacesDetailsResponse detail = await _places.getDetailsByPlaceId(p.placeId); + final lat = detail.result.geometry.location.lat; + final lng = detail.result.geometry.location.lng; + + scaffold.showSnackBar( + new SnackBar(content: new Text("${p.description} - $lat/$lng"))); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 7c1e5c8..1497f8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,17 +4,22 @@ description: All Against Fire dependencies: flutter: sdk: flutter - intl: ^0.15.6 + intl: "^0.15.6" # https://pub.dartlang.org/packages/padder - padder: ^1.0.1 + padder: "^1.0.1" # https://pub.dartlang.org/packages/flutter fluttery: "^0.0.7" shared_preferences: "^0.4.2" comunes_flutter: path: /home/vjrj/dev/comunes_flutter + version: "^0.0.4" # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^0.1.2 + cupertino_icons: "^0.1.2" + map_view: "^0.0.12" + location: "^1.3.4" + flutter_google_places_autocomplete: "^0.0.4" + get_it: "^1.0.1+1" dev_dependencies: flutter_test: @@ -39,6 +44,7 @@ flutter: assets: - images/logo-200.png + - assets/private-settings.json # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware.