[ermine] Add weather settings.

Currently hardcodes weather location to Mountain View and San
Francisco. Allow changing temperation unit.

Change-Id: I79b7af37bad368672f16c51615fb1742cc4a4aaf
diff --git a/session_shells/ermine/settings/BUILD.gn b/session_shells/ermine/settings/BUILD.gn
index 1d0b709..0dae919 100644
--- a/session_shells/ermine/settings/BUILD.gn
+++ b/session_shells/ermine/settings/BUILD.gn
@@ -13,6 +13,7 @@
     "src/battery.dart",
     "src/brightness.dart",
     "src/memory.dart",
+    "src/weather.dart",
   ]
 
   deps = [
diff --git a/session_shells/ermine/settings/lib/settings.dart b/session_shells/ermine/settings/lib/settings.dart
index 8ff6011..b5caca8 100644
--- a/session_shells/ermine/settings/lib/settings.dart
+++ b/session_shells/ermine/settings/lib/settings.dart
@@ -5,3 +5,4 @@
 export 'src/battery.dart';
 export 'src/brightness.dart';
 export 'src/memory.dart';
+export 'src/weather.dart';
diff --git a/session_shells/ermine/settings/lib/src/battery.dart b/session_shells/ermine/settings/lib/src/battery.dart
index 31c99f9..82212f7 100644
--- a/session_shells/ermine/settings/lib/src/battery.dart
+++ b/session_shells/ermine/settings/lib/src/battery.dart
@@ -96,7 +96,7 @@
   }) : _binding = binding ?? BatteryInfoWatcherBinding() {
     monitor
       ..watch(_binding.wrap(_BatteryInfoWatcherImpl(this)))
-      ..getBatteryInfo().then(updateBattery);
+      ..getBatteryInfo().then(_updateBattery);
   }
 
   void dispose() {
@@ -109,7 +109,7 @@
     onChange?.call();
   }
 
-  void updateBattery(BatteryInfo info) {
+  void _updateBattery(BatteryInfo info) {
     final chargeStatus = info.chargeStatus;
     charging = chargeStatus == ChargeStatus.charging;
     battery = info.levelPercent;
@@ -122,6 +122,6 @@
 
   @override
   Future<void> onChangeBatteryInfo(BatteryInfo info) async {
-    batteryModel.updateBattery(info);
+    batteryModel._updateBattery(info);
   }
 }
diff --git a/session_shells/ermine/settings/lib/src/weather.dart b/session_shells/ermine/settings/lib/src/weather.dart
new file mode 100644
index 0000000..f5e7c39
--- /dev/null
+++ b/session_shells/ermine/settings/lib/src/weather.dart
@@ -0,0 +1,157 @@
+// Copyright 2019 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:fidl_fuchsia_ui_remotewidgets/fidl_async.dart';
+import 'package:internationalization/strings.dart';
+import 'package:quickui/quickui.dart';
+
+const _weatherBaseUrl = 'https://api.weather.gov';
+
+/// Defines a [UiSpec] for displaying weather for locations.
+class Weather extends UiSpec {
+  // Localized strings.
+  static String get _title => Strings.weather;
+
+  static const changeUnitAction = 2;
+
+  // TODO(sanjayc): Replace hardcoded locations with user specified ones.
+  static final List<_Location> _locations = <_Location>[
+    _Location('Mountain View', '37.386051,-122.083855'),
+    _Location('San Francisco', '37.61961,-122.365579'),
+  ];
+
+  // Weather refresh duration.
+  static final _refreshDuration = Duration(minutes: 10);
+  Timer _timer;
+  bool tempInFahrenheit = true;
+
+  Weather() {
+    _timer = Timer.periodic(_refreshDuration, (_) => _onChange());
+    _onChange();
+  }
+
+  void _onChange() async {
+    try {
+      await _refresh();
+      spec = _specForWeather(tempInFahrenheit);
+    } on Exception catch (_) {
+      spec = null;
+    }
+  }
+
+  @override
+  void update(Value value) async {
+    if (value.$tag == ValueTag.button &&
+        value.button.action == changeUnitAction) {
+      tempInFahrenheit = !tempInFahrenheit;
+      spec = _specForWeather(tempInFahrenheit);
+    }
+  }
+
+  @override
+  void dispose() {
+    _timer.cancel();
+  }
+
+  static Spec _specForWeather(bool tempInFahrenheit) {
+    final locations =
+        _locations.where((location) => location.observation != null).toList();
+    if (locations.isEmpty) {
+      return null;
+    }
+    return Spec(groups: [
+      Group(title: _title, values: [
+        if (tempInFahrenheit)
+          Value.withButton(
+              ButtonValue(label: 'Use °C', action: changeUnitAction)),
+        if (!tempInFahrenheit)
+          Value.withButton(
+              ButtonValue(label: 'Use °F', action: changeUnitAction)),
+        Value.withGrid(GridValue(
+            columns: 2,
+            values: List<TextValue>.generate(locations.length * 2, (index) {
+              final location = locations[index ~/ 2];
+              final temp =
+                  tempInFahrenheit ? location.fahrenheit : location.degrees;
+              final weather = '$temp ${location.observation}';
+              return TextValue(text: index.isEven ? location.name : weather);
+            }))),
+      ]),
+    ]);
+  }
+
+  static Future<void> _refresh() async {
+    await Future.forEach(_locations, (location) async {
+      // Load the station if it is not loaded yet.
+      if (location.station == null) {
+        await _loadStation(location);
+      }
+      // Now load the current weather data for the station.
+      await _loadCurrentCondition(location);
+    });
+  }
+
+  // Get the first weather station for [_Location] point using:
+  // https://www.weather.gov/documentation/services-web-api#/default/get_stations__stationId__observations_latest
+  static Future<void> _loadStation(_Location location) async {
+    // Get the
+    final stationsUrl = '$_weatherBaseUrl/points/${location.point}/stations';
+    var request = await HttpClient().getUrl(Uri.parse(stationsUrl));
+    var response = await request.close();
+    var result = await _readResponse(response);
+    var data = json.decode(result);
+    List features = data['features'];
+    if (features.isNotEmpty) {
+      location.station = features[0]['properties']['stationIdentifier'];
+    }
+  }
+
+  // Get the latest observation for weather station in [_Location] using:
+  // https://www.weather.gov/documentation/services-web-api#/default/get_points__point__stations
+  static Future<void> _loadCurrentCondition(_Location location) async {
+    final observationUrl =
+        '$_weatherBaseUrl/stations/${location.station}/observations/latest';
+    var request = await HttpClient().getUrl(Uri.parse(observationUrl));
+    var response = await request.close();
+    var result = await _readResponse(response);
+    var data = json.decode(result);
+    var properties = data['properties'];
+    location
+      ..observation = properties['textDescription']
+      ..tempInDegrees = properties['temperature']['value'].toDouble();
+  }
+
+  // Read the string response from the [HttpClientResponse].
+  static Future<String> _readResponse(HttpClientResponse response) {
+    var completer = Completer<String>();
+    var contents = StringBuffer();
+    response.transform(utf8.decoder).listen((data) {
+      contents.write(data);
+    }, onDone: () => completer.complete(contents.toString()));
+    return completer.future;
+  }
+}
+
+// Holds weather data for a location.
+class _Location {
+  final String name;
+  final String point;
+  String observation;
+  String station;
+  double tempInDegrees;
+
+  _Location(this.name, this.point, [this.observation]);
+
+  String get degrees => '${tempInDegrees.toInt()}°C';
+
+  String get fahrenheit => '${(tempInDegrees * 1.8 + 32).toInt()}°F';
+
+  @override
+  String toString() =>
+      'name: $name station: $station observation: $observation temp: $tempInDegrees';
+}
diff --git a/session_shells/ermine/shell/lib/src/models/status_model.dart b/session_shells/ermine/shell/lib/src/models/status_model.dart
index 9aed8e1..9bdde9b 100644
--- a/session_shells/ermine/shell/lib/src/models/status_model.dart
+++ b/session_shells/ermine/shell/lib/src/models/status_model.dart
@@ -22,6 +22,7 @@
   UiStream brightness;
   UiStream memory;
   UiStream battery;
+  UiStream weather;
   final StartupContext startupContext;
   final modular.PuppetMasterProxy puppetMaster;
   final AdministratorProxy deviceManager;
@@ -30,6 +31,7 @@
     brightness = UiStream(Brightness.fromStartupContext(startupContext));
     memory = UiStream(Memory.fromStartupContext(startupContext));
     battery = UiStream(Battery.fromStartupContext(startupContext));
+    weather = UiStream(Weather());
   }
 
   factory StatusModel.fromStartupContext(StartupContext startupContext) {
@@ -52,6 +54,7 @@
     brightness.dispose();
     memory.dispose();
     battery.dispose();
+    weather.dispose();
   }
 
   /// Launch settings mod.
diff --git a/session_shells/ermine/shell/lib/src/widgets/status/status.dart b/session_shells/ermine/shell/lib/src/widgets/status/status.dart
index 444c3f1..30458ba 100644
--- a/session_shells/ermine/shell/lib/src/widgets/status/status.dart
+++ b/session_shells/ermine/shell/lib/src/widgets/status/status.dart
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'dart:ui';
+
 import 'package:fidl_fuchsia_ui_remotewidgets/fidl_async.dart';
 import 'package:flutter/material.dart';
 import 'package:internationalization/strings.dart';
@@ -42,6 +44,7 @@
           _StatusEntry(model.brightness),
           _StatusEntry(model.battery),
           _StatusEntry(model.memory),
+          _StatusEntry(model.weather),
         ],
       ),
     );
@@ -86,58 +89,25 @@
     List<Widget> result = <Widget>[];
     List<Widget> widgets = <Widget>[];
 
-    Widget titleRow(String title, List<Widget> children) {
-      return Row(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        textBaseline: TextBaseline.alphabetic,
-        children: <Widget>[
-          Container(
-            width: kTitleWidth,
-            child: Text(title.toUpperCase()),
-          ),
-          Expanded(
-            child: Wrap(
-              children: children,
-              crossAxisAlignment: WrapCrossAlignment.center,
-              alignment: WrapAlignment.end,
-              spacing: kPadding,
-              runSpacing: kPadding,
-            ),
-          ),
-        ],
-      );
-    }
-
-    Widget valueRow(List<Widget> children) {
-      return Wrap(
-        children: children,
-        crossAxisAlignment: WrapCrossAlignment.center,
-        alignment: WrapAlignment.end,
-        spacing: kPadding,
-        runSpacing: kPadding,
-      );
-    }
-
     for (final group in spec.groups) {
       for (final value in group.values) {
         final widget = _buildFromValue(value, update);
-        if (value is GridValue) {
+        if (value.$tag == ValueTag.grid) {
           if (result.isEmpty) {
-            result.add(titleRow(group.title, widgets.toList()));
+            result.add(_buildTitleRow(group.title, widgets.toList()));
           } else {
-            result.add(valueRow(widgets.toList()));
+            result.add(_buildValueRow(widgets.toList()));
           }
           widgets.clear();
-          result.add(widget);
-        } else {
-          widgets.add(widget);
         }
+        widgets.add(widget);
       }
+
       if (widgets.isNotEmpty) {
         if (result.isEmpty) {
-          result.add(titleRow(group.title, widgets.toList()));
+          result.add(_buildTitleRow(group.title, widgets.toList()));
         } else {
-          result.add(valueRow(widgets.toList()));
+          result.add(_buildValueRow(widgets.toList()));
         }
       }
     }
@@ -164,14 +134,30 @@
     }
 
     if (value.$tag == ValueTag.grid) {
-      return Padding(
-        padding: EdgeInsets.symmetric(vertical: 12),
-        child: GridView.count(
-          shrinkWrap: true,
-          childAspectRatio: 4,
-          physics: NeverScrollableScrollPhysics(),
-          crossAxisCount: value.grid.columns,
-          children: value.grid.values.map((v) => Text(v.text)).toList(),
+      int columns = value.grid.columns;
+      int rows = value.grid.values.length ~/ columns;
+      return Container(
+        padding: EdgeInsets.only(left: 8),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          children: List<Widget>.generate(rows, (row) {
+            return Padding(
+              padding: EdgeInsets.symmetric(vertical: 4),
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: List<Widget>.generate(value.grid.columns, (column) {
+                  final index = row * value.grid.columns + column;
+                  return Expanded(
+                    flex: column == 0 ? 2 : 1,
+                    child: Text(
+                      value.grid.values[index].text,
+                      textAlign: column == 0 ? TextAlign.start : TextAlign.end,
+                    ),
+                  );
+                }),
+              ),
+            );
+          }),
         ),
       );
     }
@@ -194,6 +180,49 @@
   }
 }
 
+Widget _buildValueRow(List<Widget> children) {
+  return Wrap(
+    children: children,
+    alignment: WrapAlignment.end,
+    spacing: kPadding,
+    runSpacing: kPadding,
+  );
+}
+
+Widget _buildTitleRow(String title, List<Widget> children) {
+  return Row(
+    crossAxisAlignment: CrossAxisAlignment.start,
+    textBaseline: TextBaseline.alphabetic,
+    children: <Widget>[
+      Container(
+        width: kTitleWidth,
+        child: Text(title.toUpperCase()),
+      ),
+      Expanded(
+        child: _buildValueRow(children),
+      ),
+    ],
+  );
+}
+
+Widget _buildButton(String label, void Function() onTap) {
+  return GestureDetector(
+    onTap: onTap,
+    child: Container(
+      height: kItemHeight,
+      color: Colors.white,
+      padding: EdgeInsets.symmetric(vertical: 0, horizontal: 2),
+      child: Text(
+        label.toUpperCase(),
+        style: TextStyle(
+          color: Colors.black,
+          fontWeight: FontWeight.w400,
+        ),
+      ),
+    ),
+  );
+}
+
 class _ManualStatusEntry extends StatelessWidget {
   final StatusModel model;
 
@@ -216,21 +245,3 @@
     );
   }
 }
-
-Widget _buildButton(String label, void Function() onTap) {
-  return GestureDetector(
-    onTap: onTap,
-    child: Container(
-      height: kItemHeight,
-      color: Colors.white,
-      padding: EdgeInsets.symmetric(vertical: 0, horizontal: 2),
-      child: Text(
-        label.toUpperCase(),
-        style: TextStyle(
-          color: Colors.black,
-          fontWeight: FontWeight.w400,
-        ),
-      ),
-    ),
-  );
-}