[Gallery] Add Material Study app Rally as an example app (#42236)

* Add Material Study app Rally to examples
diff --git a/examples/flutter_gallery/lib/demo/all.dart b/examples/flutter_gallery/lib/demo/all.dart
index 0dfe26b..a121dfc 100644
--- a/examples/flutter_gallery/lib/demo/all.dart
+++ b/examples/flutter_gallery/lib/demo/all.dart
@@ -11,6 +11,7 @@
 export 'images_demo.dart';
 export 'material/material.dart';
 export 'pesto_demo.dart';
+export 'rally_demo.dart';
 export 'shrine_demo.dart';
 export 'transformations/transformations_demo.dart';
 export 'typography_demo.dart';
diff --git a/examples/flutter_gallery/lib/demo/rally/app.dart b/examples/flutter_gallery/lib/demo/rally/app.dart
new file mode 100644
index 0000000..8e16d99
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/app.dart
@@ -0,0 +1,88 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gallery/demo/rally/colors.dart';
+import 'package:flutter_gallery/demo/rally/home.dart';
+import 'package:flutter_gallery/demo/rally/login.dart';
+
+/// The RallyApp is a MaterialApp with a theme and 2 routes.
+///
+/// The home route is the main page with tabs for sub pages.
+/// The login route is the initial route.
+class RallyApp extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'Rally',
+      theme: _buildRallyTheme(),
+      home: HomePage(),
+      initialRoute: '/login',
+      routes: <String, WidgetBuilder>{
+        '/login': (BuildContext context) => LoginPage(),
+      },
+    );
+  }
+
+  ThemeData _buildRallyTheme() {
+    final ThemeData base = ThemeData.dark();
+    return ThemeData(
+      scaffoldBackgroundColor: RallyColors.primaryBackground,
+      primaryColor: RallyColors.primaryBackground,
+      textTheme: _buildRallyTextTheme(base.textTheme),
+      inputDecorationTheme: InputDecorationTheme(
+        labelStyle: const TextStyle(
+          color: RallyColors.gray,
+          fontWeight: FontWeight.w500,
+        ),
+        filled: true,
+        fillColor: RallyColors.inputBackground,
+        focusedBorder: InputBorder.none,
+      ),
+    );
+  }
+
+  TextTheme _buildRallyTextTheme(TextTheme base) {
+    return base
+      .copyWith(
+        body1: base.body1.copyWith(
+          fontFamily: 'Roboto Condensed',
+          fontSize: 14,
+          fontWeight: FontWeight.w400,
+        ),
+        body2: base.body2.copyWith(
+          fontFamily: 'Eczar',
+          fontSize: 40,
+          fontWeight: FontWeight.w400,
+          letterSpacing: 1.4,
+        ),
+        button: base.button.copyWith(
+          fontFamily: 'Roboto Condensed',
+          fontWeight: FontWeight.w700,
+          letterSpacing: 2.8,
+        ),
+        headline: base.body2.copyWith(
+          fontFamily: 'Eczar',
+          fontSize: 40,
+          fontWeight: FontWeight.w600,
+          letterSpacing: 1.4,
+        ),
+      )
+      .apply(
+        displayColor: Colors.white,
+        bodyColor: Colors.white,
+      );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/charts/line_chart.dart b/examples/flutter_gallery/lib/demo/rally/charts/line_chart.dart
new file mode 100644
index 0000000..65a985e
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/charts/line_chart.dart
@@ -0,0 +1,187 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gallery/demo/rally/colors.dart';
+import 'package:flutter_gallery/demo/rally/data.dart';
+
+class RallyLineChart extends StatelessWidget {
+  const RallyLineChart({ this.events = const <DetailedEventData>[] })  : assert(events != null);
+
+  final List<DetailedEventData> events;
+
+  @override
+  Widget build(BuildContext context) {
+    return CustomPaint(painter: RallyLineChartPainter(Theme.of(context).textTheme.body1, events));
+  }
+}
+
+class RallyLineChartPainter extends CustomPainter {
+  RallyLineChartPainter(this.labelStyle, this.events);
+
+  final TextStyle labelStyle;
+
+  // Events to plot on the line as points.
+  final List<DetailedEventData> events;
+
+  // Number of days to plot.
+  // This is hardcoded to reflect the dummy data, but would be dynamic in a real
+  // app.
+  final int numDays = 52;
+
+  // Beginning of window. The end is this plus numDays.
+  // This is hardcoded to reflect the dummy data, but would be dynamic in a real
+  // app.
+  final DateTime startDate = DateTime.utc(2018, 12, 1);
+
+  // Ranges uses to lerp the pixel points.
+  // This is hardcoded to reflect the dummy data, but would be dynamic in a real
+  // app.
+  final double maxAmount = 3000; // minAmount is assumed to be 0
+
+  // The number of milliseconds in a day. This is the inherit period fot the
+  // points in this line.
+  static const int millisInDay = 24 * 60 * 60 * 1000;
+
+  // Amount to shift the tick drawing by so that the Sunday ticks do not start
+  // on the edge.
+  final int tickShift = 3;
+
+  // Arbitrary unit of space for absolute positioned painting.
+  final double space = 16;
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final double ticksTop = size.height - space * 5;
+    final double labelsTop = size.height - space * 2;
+    _drawLine(
+      canvas,
+      Rect.fromLTWH(0, 0, size.width, ticksTop),
+    );
+    _drawXAxisTicks(
+      canvas,
+      Rect.fromLTWH(0, ticksTop, size.width, labelsTop - ticksTop),
+    );
+    _drawXAxisLabels(
+      canvas,
+      Rect.fromLTWH(0, labelsTop, size.width, size.height - labelsTop),
+    );
+  }
+
+  // Since we're only using fixed dummy data, we can set this to false. In a
+  // real app we would have the data as part of the state and repaint when it's
+  // changed.
+  @override
+  bool shouldRepaint(CustomPainter oldDelegate) => false;
+
+  void _drawLine(Canvas canvas, Rect rect) {
+    final Paint linePaint = Paint()
+      ..color = RallyColors.accountColor(2)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 2;
+
+    // Arbitrary value for the first point. In a real app, a wider range of
+    // points would be used that go beyond the boundaries of the screen.
+    double lastAmount = 800;
+
+    // Try changing this value between 1, 7, 15, etc.
+    const int smoothing = 7;
+
+    // Align the points with equal deltas (1 day) as a cumulative sum.
+    int startMillis = startDate.millisecondsSinceEpoch;
+    final List<Offset> points = <Offset>[
+      Offset(0, (maxAmount - lastAmount) / maxAmount * rect.height)
+    ];
+    for (int i = 0; i < numDays + smoothing; i++) {
+      final int endMillis = startMillis + millisInDay * 1;
+      final List<DetailedEventData> filteredEvents = events.where(
+        (DetailedEventData e) {
+          return startMillis <= e.date.millisecondsSinceEpoch && e.date.millisecondsSinceEpoch <= endMillis;
+        },
+      ).toList();
+      lastAmount += sumOf<DetailedEventData>(filteredEvents, (DetailedEventData e) => e.amount);
+      final double x = i / numDays * rect.width;
+      final double y = (maxAmount - lastAmount) / maxAmount * rect.height;
+      points.add(Offset(x, y));
+      startMillis = endMillis;
+    }
+
+    final Path path = Path();
+    path.moveTo(points[0].dx, points[0].dy);
+    for (int i = 1; i < points.length - smoothing; i += smoothing) {
+      final double x1 = points[i].dx;
+      final double y1 = points[i].dy;
+      final double x2 = (x1 + points[i + smoothing].dx) / 2;
+      final double y2 = (y1 + points[i + smoothing].dy) / 2;
+      path.quadraticBezierTo(x1, y1, x2, y2);
+    }
+    canvas.drawPath(path, linePaint);
+  }
+
+  /// Draw the X-axis increment markers at constant width intervals.
+  void _drawXAxisTicks(Canvas canvas, Rect rect) {
+    for (int i = 0; i < numDays; i++) {
+      final double x = rect.width / numDays * i;
+      canvas.drawRect(
+        Rect.fromPoints(
+          Offset(x, i % 7 == tickShift ? rect.top : rect.center.dy),
+          Offset(x, rect.bottom),
+        ),
+        Paint()
+          ..style = PaintingStyle.stroke
+          ..strokeWidth = 1
+          ..color = RallyColors.gray25,
+      );
+    }
+  }
+
+  /// Set X-axis labels under the X-axis increment markers.
+  void _drawXAxisLabels(Canvas canvas, Rect rect) {
+    final TextStyle selectedLabelStyle = labelStyle.copyWith(
+      fontWeight: FontWeight.w700,
+    );
+    final TextStyle unselectedLabelStyle = labelStyle.copyWith(
+      fontWeight: FontWeight.w700,
+      color: RallyColors.gray25,
+    );
+
+    final TextPainter leftLabel = TextPainter(
+      text: TextSpan(text: 'AUGUST 2019', style: unselectedLabelStyle),
+      textDirection: TextDirection.ltr,
+    );
+    leftLabel.layout();
+    leftLabel.paint(canvas, Offset(rect.left + space / 2, rect.center.dy));
+
+    final TextPainter centerLabel = TextPainter(
+      text: TextSpan(text: 'SEPTEMBER 2019', style: selectedLabelStyle),
+      textDirection: TextDirection.ltr,
+    );
+    centerLabel.layout();
+    final double x = (rect.width - centerLabel.width) / 2;
+    final double y = rect.center.dy;
+    centerLabel.paint(canvas, Offset(x, y));
+
+    final TextPainter rightLabel = TextPainter(
+      text: TextSpan(text: 'OCTOBER 2019', style: unselectedLabelStyle),
+      textDirection: TextDirection.ltr,
+    );
+    rightLabel.layout();
+    rightLabel.paint(
+      canvas,
+      Offset(rect.right - centerLabel.width - space / 2, rect.center.dy),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/charts/pie_chart.dart b/examples/flutter_gallery/lib/demo/rally/charts/pie_chart.dart
new file mode 100644
index 0000000..3834d94
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/charts/pie_chart.dart
@@ -0,0 +1,258 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gallery/demo/rally/colors.dart';
+import 'package:flutter_gallery/demo/rally/data.dart';
+import 'package:flutter_gallery/demo/rally/formatters.dart';
+
+/// A colored piece of the [RallyPieChart].
+class RallyPieChartSegment {
+  const RallyPieChartSegment({ this.color, this.value });
+
+  final Color color;
+  final double value;
+}
+
+List<RallyPieChartSegment> buildSegmentsFromAccountItems(
+    List<AccountData> items) {
+  return List<RallyPieChartSegment>.generate(
+    items.length,
+    (int i) {
+      return RallyPieChartSegment(
+        color: RallyColors.accountColor(i),
+        value: items[i].primaryAmount,
+      );
+    },
+  );
+}
+
+List<RallyPieChartSegment> buildSegmentsFromBillItems(List<BillData> items) {
+  return List<RallyPieChartSegment>.generate(
+    items.length,
+    (int i) {
+      return RallyPieChartSegment(
+        color: RallyColors.billColor(i),
+        value: items[i].primaryAmount,
+      );
+    },
+  );
+}
+
+List<RallyPieChartSegment> buildSegmentsFromBudgetItems(
+    List<BudgetData> items) {
+  return List<RallyPieChartSegment>.generate(
+    items.length,
+    (int i) {
+      return RallyPieChartSegment(
+        color: RallyColors.budgetColor(i),
+        value: items[i].primaryAmount - items[i].amountUsed,
+      );
+    },
+  );
+}
+
+/// An animated circular pie chart to represent pieces of a whole, which can
+/// have empty space.
+class RallyPieChart extends StatefulWidget {
+  const RallyPieChart({ this.heroLabel, this.heroAmount, this.wholeAmount, this.segments });
+
+  final String heroLabel;
+  final double heroAmount;
+  final double wholeAmount;
+  final List<RallyPieChartSegment> segments;
+
+  @override
+  _RallyPieChartState createState() => _RallyPieChartState();
+}
+
+class _RallyPieChartState extends State<RallyPieChart>
+    with SingleTickerProviderStateMixin {
+  AnimationController controller;
+  Animation<double> animation;
+
+  @override
+  void initState() {
+    super.initState();
+    controller = AnimationController(
+      duration: const Duration(milliseconds: 600),
+      vsync: this,
+    );
+    animation = CurvedAnimation(
+      parent: TweenSequence<double>(<TweenSequenceItem<double>>[
+        TweenSequenceItem<double>(
+          tween: Tween<double>(begin: 0, end: 0),
+          weight: 1,
+        ),
+        TweenSequenceItem<double>(
+          tween: Tween<double>(begin: 0, end: 1),
+          weight: 1.5,
+        ),
+      ]).animate(controller),
+      curve: Curves.decelerate);
+    controller.forward();
+  }
+
+  @override
+  void dispose() {
+    controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return _AnimatedRallyPieChart(
+      animation: animation,
+      centerLabel: widget.heroLabel,
+      centerAmount: widget.heroAmount,
+      total: widget.wholeAmount,
+      segments: widget.segments,
+    );
+  }
+}
+
+class _AnimatedRallyPieChart extends AnimatedWidget {
+  const _AnimatedRallyPieChart({
+    Key key,
+    this.animation,
+    this.centerLabel,
+    this.centerAmount,
+    this.total,
+    this.segments,
+  }) : super(key: key, listenable: animation);
+
+  final Animation<double> animation;
+  final String centerLabel;
+  final double centerAmount;
+  final double total;
+  final List<RallyPieChartSegment> segments;
+
+  @override
+  Widget build(BuildContext context) {
+    final TextStyle labelTextStyle = Theme.of(context).textTheme.body1.copyWith(
+        fontSize: 14,
+        letterSpacing: 0.5,
+    );
+
+    return DecoratedBox(
+      decoration: _RallyPieChartOutlineDecoration(
+        maxFraction: animation.value,
+        total: total,
+        segments: segments,
+      ),
+      child: SizedBox(
+        height: 300,
+        child: Center(
+          child: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: <Widget>[
+              Text(
+                centerLabel,
+                style: labelTextStyle,
+              ),
+              Text(
+                usdWithSignFormat.format(centerAmount),
+                style: Theme.of(context).textTheme.headline,
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+class _RallyPieChartOutlineDecoration extends Decoration {
+  const _RallyPieChartOutlineDecoration({this.maxFraction, this.total, this.segments});
+
+  final double maxFraction;
+  final double total;
+  final List<RallyPieChartSegment> segments;
+
+  @override
+  BoxPainter createBoxPainter([VoidCallback onChanged]) {
+    return _RallyPieChartOutlineBoxPainter(
+      maxFraction: maxFraction,
+      wholeAmount: total,
+      segments: segments,
+    );
+  }
+}
+
+class _RallyPieChartOutlineBoxPainter extends BoxPainter {
+  _RallyPieChartOutlineBoxPainter({this.maxFraction, this.wholeAmount, this.segments});
+
+  final double maxFraction;
+  final double wholeAmount;
+  final List<RallyPieChartSegment> segments;
+  static const double wholeRadians = 2 * pi;
+  static const double spaceRadians = wholeRadians / 180;
+
+  @override
+  void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
+    // Create two padded reacts to draw arcs in: one for colored arcs and one for
+    // inner bg arc.
+    const double strokeWidth = 4;
+    final double outerRadius = min(
+      configuration.size.width,
+      configuration.size.height,
+    ) / 2;
+    final Rect outerRect = Rect.fromCircle(
+      center: configuration.size.center(Offset.zero),
+      radius: outerRadius - strokeWidth * 3,
+    );
+    final Rect innerRect = Rect.fromCircle(
+      center: configuration.size.center(Offset.zero),
+      radius: outerRadius - strokeWidth * 4,
+    );
+
+    // Paint each arc with spacing.
+    double cumulativeSpace = 0;
+    double cumulativeTotal = 0;
+    for (RallyPieChartSegment segment in segments) {
+      final Paint paint = Paint()..color = segment.color;
+      final double startAngle = _calculateStartAngle(cumulativeTotal, cumulativeSpace);
+      final double sweepAngle = _calculateSweepAngle(segment.value, 0);
+      canvas.drawArc(outerRect, startAngle, sweepAngle, true, paint);
+      cumulativeTotal += segment.value;
+      cumulativeSpace += spaceRadians;
+    }
+
+    // Paint any remaining space black (e.g. budget amount remaining).
+    final double remaining = wholeAmount - cumulativeTotal;
+    if (remaining > 0) {
+      final Paint paint = Paint()..color = Colors.black;
+      final double startAngle = _calculateStartAngle(cumulativeTotal, spaceRadians * segments.length);
+      final double sweepAngle = _calculateSweepAngle(remaining, -spaceRadians);
+      canvas.drawArc(outerRect, startAngle, sweepAngle, true, paint);
+    }
+
+    // Paint a smaller inner circle to cover the painted arcs, so they are
+    // display as segments.
+    final Paint bgPaint = Paint()..color = RallyColors.primaryBackground;
+    canvas.drawArc(innerRect, 0, 2 * pi, true, bgPaint);
+  }
+
+  double _calculateAngle(double amount, double offset) {
+    final double wholeMinusSpacesRadians = wholeRadians - (segments.length * spaceRadians);
+    return maxFraction * (amount / wholeAmount * wholeMinusSpacesRadians + offset);
+  }
+
+  double _calculateStartAngle(double total, double offset) => _calculateAngle(total, offset) - pi / 2;
+
+  double _calculateSweepAngle(double total, double offset) => _calculateAngle(total, offset);
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/charts/vertical_fraction_bar.dart b/examples/flutter_gallery/lib/demo/rally/charts/vertical_fraction_bar.dart
new file mode 100644
index 0000000..803487b
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/charts/vertical_fraction_bar.dart
@@ -0,0 +1,44 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/material.dart';
+
+class VerticalFractionBar extends StatelessWidget {
+  const VerticalFractionBar({ this.color, this.fraction });
+
+  final Color color;
+  final double fraction;
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: 32,
+      width: 4,
+      child: Column(
+        children: <Widget>[
+          SizedBox(
+            height: (1 - fraction) * 32,
+            child: Container(
+              color: Colors.black,
+            ),
+          ),
+          SizedBox(
+            height: fraction * 32,
+            child: Container(color: color),
+          ),
+        ],
+      ),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/colors.dart b/examples/flutter_gallery/lib/demo/rally/colors.dart
new file mode 100644
index 0000000..cc4cf32
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/colors.dart
@@ -0,0 +1,69 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'dart:ui';
+
+/// Most color assignments in Rally are not like the the typical color
+/// assignments that are common in other apps. Instead of primarily mapping to
+/// component type and part, they are assigned round robin based on layout.
+class RallyColors {
+  static const List<Color> accountColors = <Color>[
+    Color(0xFF005D57),
+    Color(0xFF04B97F),
+    Color(0xFF37EFBA),
+    Color(0xFF007D51),
+  ];
+
+  static const List<Color> billColors = <Color>[
+    Color(0xFFFFDC78),
+    Color(0xFFFF6951),
+    Color(0xFFFFD7D0),
+    Color(0xFFFFAC12),
+  ];
+
+  static const List<Color> budgetColors = <Color>[
+    Color(0xFFB2F2FF),
+    Color(0xFFB15DFF),
+    Color(0xFF72DEFF),
+    Color(0xFF0082FB),
+  ];
+
+  static const Color gray = Color(0xFFD8D8D8);
+  static const Color gray60 = Color(0x99D8D8D8);
+  static const Color gray25 = Color(0x40D8D8D8);
+  static const Color white60 = Color(0x99FFFFFF);
+  static const Color primaryBackground = Color(0xFF33333D);
+  static const Color inputBackground = Color(0xFF26282F);
+  static const Color cardBackground = Color(0x03FEFEFE);
+
+  /// Convenience method to get a single account color with position i.
+  static Color accountColor(int i) {
+    return cycledColor(accountColors, i);
+  }
+
+  /// Convenience method to get a single bill color with position i.
+  static Color billColor(int i) {
+    return cycledColor(billColors, i);
+  }
+
+  /// Convenience method to get a single budget color with position i.
+  static Color budgetColor(int i) {
+    return cycledColor(budgetColors, i);
+  }
+
+  /// Gets a color from a list that is considered to be infinitely repeating.
+  static Color cycledColor(List<Color> colors, int i) {
+    return colors[i % colors.length];
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/data.dart b/examples/flutter_gallery/lib/demo/rally/data.dart
new file mode 100644
index 0000000..ff681a8
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/data.dart
@@ -0,0 +1,228 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/// Calculates the sum of the primary amounts of a list of [AccountData].
+double sumAccountDataPrimaryAmount(List<AccountData> items) => sumOf<AccountData>(items, (AccountData item) => item.primaryAmount);
+
+/// Calculates the sum of the primary amounts of a list of [BillData].
+double sumBillDataPrimaryAmount(List<BillData> items) => sumOf<BillData>(items, (BillData item) => item.primaryAmount);
+
+/// Calculates the sum of the primary amounts of a list of [BudgetData].
+double sumBudgetDataPrimaryAmount(List<BudgetData> items) => sumOf<BudgetData>(items, (BudgetData item) => item.primaryAmount);
+
+/// Calculates the sum of the amounts used of a list of [BudgetData].
+double sumBudgetDataAmountUsed(List<BudgetData> items) => sumOf<BudgetData>(items, (BudgetData item) => item.amountUsed);
+
+/// Utility function to sum up values in a list.
+double sumOf<T>(List<T> list, double getValue(T elt)) {
+  double sum = 0;
+  for (T elt in list)
+    sum += getValue(elt);
+  return sum;
+}
+
+
+/// A data model for an account.
+///
+/// The [primaryAmount] is the balance of the account in USD.
+class AccountData {
+  const AccountData({this.name, this.primaryAmount, this.accountNumber});
+
+  /// The display name of this entity.
+  final String name;
+
+  // The primary amount or value of this entity.
+  final double primaryAmount;
+
+  /// The full displayable account number.
+  final String accountNumber;
+}
+
+/// A data model for a bill.
+///
+/// The [primaryAmount] is the amount due in USD.
+class BillData {
+  const BillData({this.name, this.primaryAmount, this.dueDate});
+
+  /// The display name of this entity.
+  final String name;
+
+  // The primary amount or value of this entity.
+  final double primaryAmount;
+
+  /// The due date of this bill.
+  final String dueDate;
+}
+
+/// A data model for a budget.
+///
+/// The [primaryAmount] is the budget cap in USD.
+class BudgetData {
+  const BudgetData({this.name, this.primaryAmount, this.amountUsed});
+
+  /// The display name of this entity.
+  final String name;
+
+  // The primary amount or value of this entity.
+  final double primaryAmount;
+
+  /// Amount of the budget that is consumed or used.
+  final double amountUsed;
+}
+
+class DetailedEventData {
+  const DetailedEventData({
+    this.title,
+    this.date,
+    this.amount,
+  });
+
+  final String title;
+  final DateTime date;
+  final double amount;
+}
+
+/// Class to return dummy data lists.
+///
+/// In a real app, this might be replaced with some asynchronous service.
+class DummyDataService {
+  static List<AccountData> getAccountDataList() {
+    return <AccountData>[
+      const AccountData(
+        name: 'Checking',
+        primaryAmount: 2215.13,
+        accountNumber: '1234561234',
+      ),
+      const AccountData(
+        name: 'Home Savings',
+        primaryAmount: 8678.88,
+        accountNumber: '8888885678',
+      ),
+      const AccountData(
+        name: 'Car Savings',
+        primaryAmount: 987.48,
+        accountNumber: '8888889012',
+      ),
+      const AccountData(
+        name: 'Vacation',
+        primaryAmount: 253,
+        accountNumber: '1231233456',
+      ),
+    ];
+  }
+
+  static List<DetailedEventData> getDetailedEventItems() {
+    return <DetailedEventData>[
+      DetailedEventData(
+        title: 'Genoe',
+        date: DateTime.utc(2019, 1, 24),
+        amount: -16.54,
+      ),
+      DetailedEventData(
+        title: 'Fortnightly Subscribe',
+        date: DateTime.utc(2019, 1, 5),
+        amount: -12.54,
+      ),
+      DetailedEventData(
+        title: 'Circle Cash',
+        date: DateTime.utc(2019, 1, 5),
+        amount: 365.65,
+      ),
+      DetailedEventData(
+        title: 'Crane Hospitality',
+        date: DateTime.utc(2019, 1, 4),
+        amount: -705.13,
+      ),
+      DetailedEventData(
+        title: 'ABC Payroll',
+        date: DateTime.utc(2018, 12, 15),
+        amount: 1141.43,
+      ),
+      DetailedEventData(
+        title: 'Shrine',
+        date: DateTime.utc(2018, 12, 15),
+        amount: -88.88,
+      ),
+      DetailedEventData(
+        title: 'Foodmates',
+        date: DateTime.utc(2018, 12, 4),
+        amount: -11.69,
+      ),
+    ];
+  }
+
+  static List<BillData> getBillDataList() {
+    return <BillData>[
+      const BillData(
+        name: 'RedPay Credit',
+        primaryAmount: 45.36,
+        dueDate: 'Jan 29',
+      ),
+      const BillData(
+        name: 'Rent',
+        primaryAmount: 1200,
+        dueDate: 'Feb 9',
+      ),
+      const BillData(
+        name: 'TabFine Credit',
+        primaryAmount: 87.33,
+        dueDate: 'Feb 22',
+      ),
+      const BillData(
+        name: 'ABC Loans',
+        primaryAmount: 400,
+        dueDate: 'Feb 29',
+      ),
+    ];
+  }
+
+  static List<BudgetData> getBudgetDataList() {
+    return <BudgetData>[
+      const BudgetData(
+        name: 'Coffee Shops',
+        primaryAmount: 70,
+        amountUsed: 45.49,
+      ),
+      const BudgetData(
+        name: 'Groceries',
+        primaryAmount: 170,
+        amountUsed: 16.45,
+      ),
+      const BudgetData(
+        name: 'Restaurants',
+        primaryAmount: 170,
+        amountUsed: 123.25,
+      ),
+      const BudgetData(
+        name: 'Clothing',
+        primaryAmount: 70,
+        amountUsed: 19.45,
+      ),
+    ];
+  }
+
+  static List<String> getSettingsTitles() {
+    return <String>[
+      'Manage Accounts',
+      'Tax Documents',
+      'Passcode and Touch ID',
+      'Notifications',
+      'Personal Information',
+      'Paperless Settings',
+      'Find ATMs',
+      'Help',
+      'Sign out',
+    ];
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/finance.dart b/examples/flutter_gallery/lib/demo/rally/finance.dart
new file mode 100644
index 0000000..03bcec4
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/finance.dart
@@ -0,0 +1,330 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gallery/demo/rally/charts/pie_chart.dart';
+import 'package:flutter_gallery/demo/rally/charts/line_chart.dart';
+import 'package:flutter_gallery/demo/rally/charts/vertical_fraction_bar.dart';
+import 'package:flutter_gallery/demo/rally/colors.dart';
+import 'package:flutter_gallery/demo/rally/data.dart';
+import 'package:flutter_gallery/demo/rally/formatters.dart';
+
+class FinancialEntityView extends StatelessWidget {
+  const FinancialEntityView({
+    this.heroLabel,
+    this.heroAmount,
+    this.wholeAmount,
+    this.segments,
+    this.financialEntityCards,
+  }) : assert(segments.length == financialEntityCards.length);
+
+  /// The amounts to assign each item.
+  final List<RallyPieChartSegment> segments;
+  final String heroLabel;
+  final double heroAmount;
+  final double wholeAmount;
+  final List<FinancialEntityCategoryView> financialEntityCards;
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      children: <Widget>[
+        RallyPieChart(
+          heroLabel: heroLabel,
+          heroAmount: heroAmount,
+          wholeAmount: wholeAmount,
+          segments: segments,
+        ),
+        SizedBox(
+          height: 1,
+          child: Container(
+            color: const Color(0xA026282F),
+          ),
+        ),
+        ListView(shrinkWrap: true, children: financialEntityCards),
+      ],
+    );
+  }
+}
+
+/// A reusable widget to show balance information of a single entity as a card.
+class FinancialEntityCategoryView extends StatelessWidget {
+  const FinancialEntityCategoryView({
+    @required this.indicatorColor,
+    @required this.indicatorFraction,
+    @required this.title,
+    @required this.subtitle,
+    @required this.amount,
+    @required this.suffix,
+  });
+
+  final Color indicatorColor;
+  final double indicatorFraction;
+  final String title;
+  final String subtitle;
+  final double amount;
+  final Widget suffix;
+
+  @override
+  Widget build(BuildContext context) {
+    return FlatButton(
+      onPressed: () {
+        Navigator.push(
+          context,
+          MaterialPageRoute<FinancialEntityCategoryDetailsPage>(
+            builder: (BuildContext context) => FinancialEntityCategoryDetailsPage(),
+          ),
+        );
+      },
+      child: SizedBox(
+        height: 68,
+        child: Column(
+          children: <Widget>[
+            Expanded(
+              child: Row(
+                children: <Widget>[
+                  Padding(
+                    padding: const EdgeInsets.only(left: 12, right: 12),
+                    child: VerticalFractionBar(
+                      color: indicatorColor,
+                      fraction: indicatorFraction,
+                    ),
+                  ),
+                  Column(
+                    mainAxisAlignment: MainAxisAlignment.center,
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: <Widget>[
+                      Text(
+                        title,
+                        style: Theme.of(context).textTheme.body1.copyWith(fontSize: 16),
+                      ),
+                      Text(
+                        subtitle,
+                        style: Theme.of(context).textTheme.body1.copyWith(color: RallyColors.gray60),
+                      ),
+                    ],
+                  ),
+                  const Spacer(),
+                  Text(
+                    '\$ ' + usdFormat.format(amount),
+                    style: Theme.of(context).textTheme.body2.copyWith(fontSize: 20, color: RallyColors.gray),
+                  ),
+                  SizedBox(width: 32, child: suffix),
+                ],
+              ),
+            ),
+            const Divider(
+              height: 1,
+              indent: 16,
+              endIndent: 16,
+              color: Color(0xAA282828),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+/// Data model for [FinancialEntityCategoryView].
+class FinancialEntityCategoryModel {
+  const FinancialEntityCategoryModel(
+    this.indicatorColor,
+    this.indicatorFraction,
+    this.title,
+    this.subtitle,
+    this.usdAmount,
+    this.suffix,
+  );
+
+  final Color indicatorColor;
+  final double indicatorFraction;
+  final String title;
+  final String subtitle;
+  final double usdAmount;
+  final Widget suffix;
+}
+
+FinancialEntityCategoryView buildFinancialEntityFromAccountData(
+  AccountData model,
+  int accountDataIndex,
+) {
+  return FinancialEntityCategoryView(
+    suffix: const Icon(Icons.chevron_right, color: Colors.grey),
+    title: model.name,
+    subtitle: '• • • • • • ${model.accountNumber.substring(6)}',
+    indicatorColor: RallyColors.accountColor(accountDataIndex),
+    indicatorFraction: 1,
+    amount: model.primaryAmount,
+  );
+}
+
+FinancialEntityCategoryView buildFinancialEntityFromBillData(
+  BillData model,
+  int billDataInex,
+) {
+  return FinancialEntityCategoryView(
+    suffix: const Icon(Icons.chevron_right, color: Colors.grey),
+    title: model.name,
+    subtitle: model.dueDate,
+    indicatorColor: RallyColors.billColor(billDataInex),
+    indicatorFraction: 1,
+    amount: model.primaryAmount,
+  );
+}
+
+FinancialEntityCategoryView buildFinancialEntityFromBudgetData(
+  BudgetData item,
+  int budgetDataIndex,
+  BuildContext context,
+) {
+  final String amountUsed = usdWithSignFormat.format(item.amountUsed);
+  final String primaryAmount = usdWithSignFormat.format(item.primaryAmount);
+
+  return FinancialEntityCategoryView(
+    suffix: Text(
+      ' LEFT',
+      style: Theme.of(context).textTheme.body1.copyWith(color: RallyColors.gray60, fontSize: 10),
+    ),
+    title: item.name,
+    subtitle: amountUsed + ' / ' + primaryAmount,
+    indicatorColor: RallyColors.budgetColor(budgetDataIndex),
+    indicatorFraction: item.amountUsed / item.primaryAmount,
+    amount: item.primaryAmount - item.amountUsed,
+  );
+}
+
+List<FinancialEntityCategoryView> buildAccountDataListViews(
+    List<AccountData> items) {
+  return List<FinancialEntityCategoryView>.generate(
+    items.length,
+    (int i) => buildFinancialEntityFromAccountData(items[i], i),
+  );
+}
+
+List<FinancialEntityCategoryView> buildBillDataListViews(List<BillData> items) {
+  return List<FinancialEntityCategoryView>.generate(
+    items.length,
+    (int i) => buildFinancialEntityFromBillData(items[i], i),
+  );
+}
+
+List<FinancialEntityCategoryView> buildBudgetDataListViews(
+    List<BudgetData> items, BuildContext context) {
+  return <FinancialEntityCategoryView>[
+    for (int i = 0; i < items.length; i++)
+      buildFinancialEntityFromBudgetData(items[i], i, context)
+  ];
+}
+
+class FinancialEntityCategoryDetailsPage extends StatelessWidget {
+  final List<DetailedEventData> items = DummyDataService.getDetailedEventItems();
+
+  @override
+  Widget build(BuildContext context) {
+    final List<_DetailedEventCard> cards = items.map((DetailedEventData detailedEventData) {
+      return _DetailedEventCard(
+        title: detailedEventData.title,
+        subtitle: dateFormat.format(detailedEventData.date),
+        amount: detailedEventData.amount,
+      );
+    }).toList();
+
+    return Scaffold(
+      appBar: AppBar(
+        elevation: 0,
+        centerTitle: true,
+        title: Text(
+          'Checking',
+          style: Theme.of(context).textTheme.body1.copyWith(fontSize: 18),
+        ),
+      ),
+      body: Column(
+        children: <Widget>[
+          SizedBox(
+            height: 200,
+            width: double.infinity,
+            child: RallyLineChart(events: items),
+          ),
+          Flexible(
+            child: ListView(shrinkWrap: true, children: cards),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _DetailedEventCard extends StatelessWidget {
+  const _DetailedEventCard({
+    @required this.title,
+    @required this.subtitle,
+    @required this.amount,
+  });
+
+  final String title;
+  final String subtitle;
+  final double amount;
+
+  @override
+  Widget build(BuildContext context) {
+    final TextTheme textTheme = Theme.of(context).textTheme;
+    return FlatButton(
+      onPressed: () {},
+      child: SizedBox(
+        height: 68,
+        child: Column(
+          children: <Widget>[
+            SizedBox(
+              height: 67,
+              child: Row(
+                children: <Widget>[
+                  Column(
+                    mainAxisAlignment: MainAxisAlignment.center,
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: <Widget>[
+                      Text(
+                        title,
+                        style: textTheme.body1.copyWith(fontSize: 16),
+                      ),
+                      Text(
+                        subtitle,
+                        style: textTheme.body1.copyWith(color: RallyColors.gray60),
+                      )
+                    ],
+                  ),
+                  const Spacer(),
+                  Text(
+                    '\$${usdFormat.format(amount)}',
+                    style: textTheme.body2.copyWith(fontSize: 20, color: RallyColors.gray),
+                  ),
+                ],
+              ),
+            ),
+            Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 16),
+              child: SizedBox(
+                height: 1,
+                child: Container(
+                  color: const Color(0xAA282828),
+                ),
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/formatters.dart b/examples/flutter_gallery/lib/demo/rally/formatters.dart
new file mode 100644
index 0000000..4fa8ca3
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/formatters.dart
@@ -0,0 +1,19 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:intl/intl.dart';
+
+NumberFormat usdFormat = NumberFormat.currency(name: '');
+NumberFormat usdWithSignFormat = NumberFormat.currency(name: '\$');
+DateFormat dateFormat = DateFormat('MM-dd-yy');
diff --git a/examples/flutter_gallery/lib/demo/rally/home.dart b/examples/flutter_gallery/lib/demo/rally/home.dart
new file mode 100644
index 0000000..8b64419
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/home.dart
@@ -0,0 +1,206 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gallery/demo/rally/tabs/accounts.dart';
+import 'package:flutter_gallery/demo/rally/tabs/bills.dart';
+import 'package:flutter_gallery/demo/rally/tabs/budgets.dart';
+import 'package:flutter_gallery/demo/rally/tabs/overview.dart';
+import 'package:flutter_gallery/demo/rally/tabs/settings.dart';
+
+const int tabCount = 5;
+
+class HomePage extends StatefulWidget {
+  @override
+  _HomePageState createState() => _HomePageState();
+}
+
+class _HomePageState extends State<HomePage>
+    with SingleTickerProviderStateMixin {
+
+  TabController _tabController;
+
+  @override
+  void initState() {
+    super.initState();
+    _tabController = TabController(length: tabCount, vsync: this)
+     ..addListener(() {
+       // Set state to make sure that the [_RallyTab] widgets get updated when changing tabs.
+      setState(() { });
+    });
+  }
+
+  @override
+  void dispose() {
+    _tabController.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final ThemeData theme = Theme.of(context);
+
+    return Scaffold(
+      body: SafeArea(
+        child: Column(
+          children: <Widget>[
+            Theme(
+              // This theme effectively removes the default visual touch
+              // feedback for tapping a tab, which is replaced with a custom
+              // animation.
+              data: theme.copyWith(
+                splashColor: Colors.transparent,
+                highlightColor: Colors.transparent,
+              ),
+              child: TabBar(
+                // Setting isScrollable to true prevents the tabs from being
+                // wrapped in [Expanded] widgets, which allows for more
+                // flexible sizes and size animations among tabs.
+                isScrollable: true,
+                labelPadding: EdgeInsets.zero,
+                tabs: _buildTabs(theme),
+                controller: _tabController,
+                // This hides the tab indicator.
+                indicatorColor: Colors.transparent,
+              ),
+            ),
+            Expanded(
+              child: TabBarView(
+                controller: _tabController,
+                children: _buildTabViews(),
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  List<Widget> _buildTabs(ThemeData theme) {
+    return <Widget>[
+      _RallyTab(theme, Icons.pie_chart, 'OVERVIEW', 0, _tabController),
+      _RallyTab(theme, Icons.attach_money, 'ACCOUNTS', 1, _tabController),
+      _RallyTab(theme, Icons.money_off, 'BILLS', 2, _tabController),
+      _RallyTab(theme, Icons.table_chart, 'BUDGETS', 3, _tabController),
+      _RallyTab(theme, Icons.settings, 'SETTINGS', 4, _tabController),
+    ];
+  }
+
+  List<Widget> _buildTabViews() {
+    return <Widget>[
+      OverviewView(),
+      AccountsView(),
+      BillsView(),
+      BudgetsView(),
+      SettingsView(),
+    ];
+  }
+}
+
+class _RallyTab extends StatefulWidget {
+  _RallyTab(
+    ThemeData theme,
+    IconData iconData,
+    String title,
+    int tabIndex,
+    TabController tabController,
+  ) : titleText = Text(title, style: theme.textTheme.button),
+      isExpanded = tabController.index == tabIndex,
+      icon = Icon(iconData);
+
+  final Text titleText;
+  final Icon icon;
+  final bool isExpanded;
+
+  @override
+  _RallyTabState createState() => _RallyTabState();
+}
+
+class _RallyTabState extends State<_RallyTab>
+    with SingleTickerProviderStateMixin {
+  Animation<double> _titleSizeAnimation;
+  Animation<double> _titleFadeAnimation;
+  Animation<double> _iconFadeAnimation;
+  AnimationController _controller;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      duration: const Duration(milliseconds: 200),
+      vsync: this,
+    );
+    _titleSizeAnimation = _controller.view;
+    _titleFadeAnimation = _controller.drive(CurveTween(curve: Curves.easeOut));
+    _iconFadeAnimation = _controller.drive(Tween<double>(begin: 0.6, end: 1));
+    if (widget.isExpanded) {
+      _controller.value = 1;
+    }
+  }
+
+  @override
+  void didUpdateWidget(_RallyTab oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.isExpanded) {
+      _controller.forward();
+    } else {
+      _controller.reverse();
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    // Calculate the width of each unexpanded tab by counting the number of
+    // units and dividing it into the screen width. Each unexpanded tab is 1
+    // unit, and there is always 1 expanded tab which is 1 unit + any extra
+    // space determined by the multiplier.
+    final double width = MediaQuery.of(context).size.width;
+    const double expandedTitleWidthMultiplier = 2;
+    final double unitWidth = width / (tabCount + expandedTitleWidthMultiplier);
+
+    return SizedBox(
+      height: 56,
+      child: Row(
+        children: <Widget>[
+          FadeTransition(
+            child: SizedBox(
+              width: unitWidth,
+              child: widget.icon,
+            ),
+            opacity: _iconFadeAnimation,
+          ),
+          FadeTransition(
+            child: SizeTransition(
+              child: SizedBox(
+                width: unitWidth * expandedTitleWidthMultiplier,
+                child: Center(child: widget.titleText),
+              ),
+              axis: Axis.horizontal,
+              axisAlignment: -1,
+              sizeFactor: _titleSizeAnimation,
+            ),
+            opacity: _titleFadeAnimation,
+          ),
+        ],
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/login.dart b/examples/flutter_gallery/lib/demo/rally/login.dart
new file mode 100644
index 0000000..c4401e9
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/login.dart
@@ -0,0 +1,91 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/material.dart';
+
+class LoginPage extends StatefulWidget {
+  @override
+  _LoginPageState createState() => _LoginPageState();
+}
+
+class _LoginPageState extends State<LoginPage> {
+  final TextEditingController _usernameController = TextEditingController();
+  final TextEditingController _passwordController = TextEditingController();
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        elevation: 0,
+        leading: IconButton(
+          icon: const BackButtonIcon(),
+          tooltip: MaterialLocalizations.of(context).backButtonTooltip,
+          onPressed: () {
+            Navigator.of(context, rootNavigator: true).pop();
+          },
+        ),
+      ),
+      body: SafeArea(
+        child: GestureDetector(
+          onTap: () {
+            Navigator.pop(context);
+          },
+          child: ListView(
+            padding: const EdgeInsets.symmetric(horizontal: 24),
+            children: <Widget>[
+              Padding(
+                padding: const EdgeInsets.symmetric(vertical: 64),
+                child: SizedBox(
+                  height: 160,
+                  child: Image.asset(
+                    'logo.png',
+                    package: 'rally_assets',
+                  ),
+                ),
+              ),
+              TextField(
+                controller: _usernameController,
+                decoration: const InputDecoration(
+                  labelText: 'Username',
+                ),
+              ),
+              const SizedBox(height: 12),
+              TextField(
+                controller: _passwordController,
+                decoration: const InputDecoration(
+                  labelText: 'Password',
+                ),
+                obscureText: true,
+              ),
+              SizedBox(
+                height: 120,
+                child: Image.asset(
+                  'thumb.png',
+                  package: 'rally_assets',
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _usernameController.dispose();
+    _passwordController.dispose();
+    super.dispose();
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/main.dart b/examples/flutter_gallery/lib/demo/rally/main.dart
new file mode 100644
index 0000000..14fd0de
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/main.dart
@@ -0,0 +1,18 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_gallery/demo/rally/app.dart';
+
+void main() => runApp(RallyApp());
diff --git a/examples/flutter_gallery/lib/demo/rally/tabs/accounts.dart b/examples/flutter_gallery/lib/demo/rally/tabs/accounts.dart
new file mode 100644
index 0000000..10ac7c4
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/tabs/accounts.dart
@@ -0,0 +1,36 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/widgets.dart';
+
+import 'package:flutter_gallery/demo/rally/data.dart';
+import 'package:flutter_gallery/demo/rally/finance.dart';
+import 'package:flutter_gallery/demo/rally/charts/pie_chart.dart';
+
+/// A page that shows a summary of accounts.
+class AccountsView extends StatelessWidget {
+  final List<AccountData> items = DummyDataService.getAccountDataList();
+
+  @override
+  Widget build(BuildContext context) {
+    final double balanceTotal = sumAccountDataPrimaryAmount(items);
+    return FinancialEntityView(
+      heroLabel: 'Total',
+      heroAmount: balanceTotal,
+      segments: buildSegmentsFromAccountItems(items),
+      wholeAmount: balanceTotal,
+      financialEntityCards: buildAccountDataListViews(items),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/tabs/bills.dart b/examples/flutter_gallery/lib/demo/rally/tabs/bills.dart
new file mode 100644
index 0000000..2d1053a
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/tabs/bills.dart
@@ -0,0 +1,42 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_gallery/demo/rally/data.dart';
+import 'package:flutter_gallery/demo/rally/finance.dart';
+import 'package:flutter_gallery/demo/rally/charts/pie_chart.dart';
+
+/// A page that shows a summary of bills.
+class BillsView extends StatefulWidget {
+  @override
+  _BillsViewState createState() => _BillsViewState();
+}
+
+class _BillsViewState extends State<BillsView>
+    with SingleTickerProviderStateMixin {
+  final List<BillData> items = DummyDataService.getBillDataList();
+
+  @override
+  Widget build(BuildContext context) {
+    final double dueTotal = sumBillDataPrimaryAmount(items);
+    return FinancialEntityView(
+      heroLabel: 'Due',
+      heroAmount: dueTotal,
+      segments: buildSegmentsFromBillItems(items),
+      wholeAmount: dueTotal,
+      financialEntityCards: buildBillDataListViews(items),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/tabs/budgets.dart b/examples/flutter_gallery/lib/demo/rally/tabs/budgets.dart
new file mode 100644
index 0000000..f22315c
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/tabs/budgets.dart
@@ -0,0 +1,42 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/widgets.dart';
+
+import 'package:flutter_gallery/demo/rally/charts/pie_chart.dart';
+import 'package:flutter_gallery/demo/rally/data.dart';
+import 'package:flutter_gallery/demo/rally/finance.dart';
+
+class BudgetsView extends StatefulWidget {
+  @override
+  _BudgetsViewState createState() => _BudgetsViewState();
+}
+
+class _BudgetsViewState extends State<BudgetsView>
+    with SingleTickerProviderStateMixin {
+  final List<BudgetData> items = DummyDataService.getBudgetDataList();
+
+  @override
+  Widget build(BuildContext context) {
+    final double capTotal = sumBudgetDataPrimaryAmount(items);
+    final double usedTotal = sumBudgetDataAmountUsed(items);
+    return FinancialEntityView(
+      heroLabel: 'Left',
+      heroAmount: capTotal - usedTotal,
+      segments: buildSegmentsFromBudgetItems(items),
+      wholeAmount: capTotal,
+      financialEntityCards: buildBudgetDataListViews(items, context),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/tabs/overview.dart b/examples/flutter_gallery/lib/demo/rally/tabs/overview.dart
new file mode 100644
index 0000000..7d8f1f3
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/tabs/overview.dart
@@ -0,0 +1,151 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gallery/demo/rally/colors.dart';
+import 'package:flutter_gallery/demo/rally/data.dart';
+import 'package:flutter_gallery/demo/rally/finance.dart';
+import 'package:flutter_gallery/demo/rally/formatters.dart';
+
+/// A page that shows a status overview.
+class OverviewView extends StatefulWidget {
+  @override
+  _OverviewViewState createState() => _OverviewViewState();
+}
+
+class _OverviewViewState extends State<OverviewView> {
+  @override
+  Widget build(BuildContext context) {
+    final List<AccountData> accountDataList = DummyDataService.getAccountDataList();
+    final List<BillData> billDataList = DummyDataService.getBillDataList();
+    final List<BudgetData> budgetDataList = DummyDataService.getBudgetDataList();
+
+    return Padding(
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+      child: ListView(
+        children: <Widget>[
+          _AlertsView(),
+          const SizedBox(height: 16),
+          _FinancialView(
+            title: 'Accounts',
+            total: sumAccountDataPrimaryAmount(accountDataList),
+            financialItemViews: buildAccountDataListViews(accountDataList),
+          ),
+          const SizedBox(height: 16),
+          _FinancialView(
+            title: 'Bills',
+            total: sumBillDataPrimaryAmount(billDataList),
+            financialItemViews: buildBillDataListViews(billDataList),
+          ),
+          const SizedBox(height: 16),
+          _FinancialView(
+            title: 'Budgets',
+            total: sumBudgetDataPrimaryAmount(budgetDataList),
+            financialItemViews:
+                buildBudgetDataListViews(budgetDataList, context),
+          ),
+          const SizedBox(height: 16),
+        ],
+      ),
+    );
+  }
+}
+
+class _AlertsView extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4),
+      color: RallyColors.cardBackground,
+      child: Column(
+        children: <Widget>[
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: <Widget>[
+              const Text('Alerts'),
+              FlatButton(
+                onPressed: () {},
+                child: const Text('SEE ALL'),
+                textColor: Colors.white,
+              ),
+            ],
+          ),
+          Container(color: RallyColors.primaryBackground, height: 1),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: <Widget>[
+              const Expanded(
+                child: Text('Heads up, you’ve used up 90% of your Shopping budget for this month.'),
+              ),
+              SizedBox(
+                width: 100,
+                child: Align(
+                  alignment: Alignment.topRight,
+                  child: IconButton(
+                    onPressed: () {},
+                    icon: Icon(Icons.sort, color: RallyColors.white60),
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _FinancialView extends StatelessWidget {
+  const _FinancialView({this.title, this.total, this.financialItemViews});
+
+  final String title;
+  final double total;
+  final List<FinancialEntityCategoryView> financialItemViews;
+
+  @override
+  Widget build(BuildContext context) {
+    final ThemeData theme = Theme.of(context);
+    return Container(
+      color: RallyColors.cardBackground,
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        children: <Widget>[
+          Padding(
+            padding: const EdgeInsets.all(16),
+            child: Text(title),
+          ),
+          Padding(
+            padding: const EdgeInsets.only(left: 16, right: 16),
+            child: Text(
+              usdWithSignFormat.format(total),
+              style: theme.textTheme.body2.copyWith(
+                fontSize: 44,
+                fontWeight: FontWeight.w600,
+              ),
+            ),
+          ),
+          ...financialItemViews.sublist(0, min(financialItemViews.length, 3)),
+          FlatButton(
+            child: const Text('SEE ALL'),
+            textColor: Colors.white,
+            onPressed: () {},
+          ),
+        ],
+      ),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally/tabs/settings.dart b/examples/flutter_gallery/lib/demo/rally/tabs/settings.dart
new file mode 100644
index 0000000..4883f87
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally/tabs/settings.dart
@@ -0,0 +1,59 @@
+// Copyright 2019-present the Flutter authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import 'package:flutter/material.dart';
+
+import 'package:flutter_gallery/demo/rally/data.dart';
+import 'package:flutter_gallery/demo/rally/login.dart';
+
+class SettingsView extends StatefulWidget {
+  @override
+  _SettingsViewState createState() => _SettingsViewState();
+}
+
+class _SettingsViewState extends State<SettingsView> {
+  List<Widget> items = DummyDataService.getSettingsTitles()
+      .map((String title) => _SettingsItem(title))
+      .toList();
+
+  @override
+  Widget build(BuildContext context) {
+    return ListView(children: items);
+  }
+}
+
+class _SettingsItem extends StatelessWidget {
+  const _SettingsItem(this.title);
+
+  final String title;
+
+  @override
+  Widget build(BuildContext context) {
+    return FlatButton(
+      textColor: Colors.white,
+      child: SizedBox(
+        height: 60,
+        child: Row(children: <Widget>[
+          Text(title),
+        ]),
+      ),
+      onPressed: () {
+        Navigator.push<void>(
+          context,
+          MaterialPageRoute<void>(builder: (BuildContext context) => LoginPage()),
+        );
+      },
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/rally_demo.dart b/examples/flutter_gallery/lib/demo/rally_demo.dart
new file mode 100644
index 0000000..6ff0df2
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/rally_demo.dart
@@ -0,0 +1,15 @@
+// Copyright 2019 The Chromium 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 'package:flutter/material.dart';
+import 'package:flutter_gallery/demo/rally/app.dart';
+
+class RallyDemo extends StatelessWidget {
+  const RallyDemo({ Key key }) : super(key: key);
+
+  static const String routeName = '/rally'; // Used by the Gallery app.
+
+  @override
+  Widget build(BuildContext context) => RallyApp();
+}
diff --git a/examples/flutter_gallery/lib/gallery/demos.dart b/examples/flutter_gallery/lib/gallery/demos.dart
index a212305..05c0ee1 100644
--- a/examples/flutter_gallery/lib/gallery/demos.dart
+++ b/examples/flutter_gallery/lib/gallery/demos.dart
@@ -131,6 +131,14 @@
       routeName: TransformationsDemo.routeName,
       buildRoute: (BuildContext context) => const TransformationsDemo(),
     ),
+    GalleryDemo(
+      title: 'Rally',
+      subtitle: 'A personal finance app',
+      icon: GalleryIcons.data_table,
+      category: _kDemos,
+      routeName: RallyDemo.routeName,
+      buildRoute: (BuildContext context) => const RallyDemo(),
+    ),
 
     // Style
     GalleryDemo(
diff --git a/examples/flutter_gallery/pubspec.yaml b/examples/flutter_gallery/pubspec.yaml
index 4d150c3..bee1c8b 100644
--- a/examples/flutter_gallery/pubspec.yaml
+++ b/examples/flutter_gallery/pubspec.yaml
@@ -17,6 +17,7 @@
   video_player: 0.10.2+5
   scoped_model: 1.0.1
   shrine_images: 1.1.2
+  rally_assets: 1.0.0
 
   # Also update dev/benchmarks/complex_layout/pubspec.yaml
   flutter_gallery_assets: 0.1.9+2
@@ -199,6 +200,8 @@
     - packages/shrine_images/35-0.jpg
     - packages/shrine_images/36-0.jpg
     - packages/shrine_images/37-0.jpg
+    - packages/rally_assets/logo.png
+    - packages/rally_assets/thumb.png
 
   fonts:
     - family: Raleway
@@ -260,5 +263,21 @@
       - asset: packages/flutter_gallery_assets/fonts/merriweather/Merriweather-Italic.ttf
       - asset: packages/flutter_gallery_assets/fonts/merriweather/Merriweather-Regular.ttf
       - asset: packages/flutter_gallery_assets/fonts/merriweather/Merriweather-Light.ttf
+    - family: Roboto Condensed
+      fonts:
+      - asset: packages/rally_assets/RobotoCondensed-Light.ttf
+        weight: 400
+      - asset: packages/rally_assets/RobotoCondensed-Regular.ttf
+        weight: 500
+      - asset: packages/rally_assets/RobotoCondensed-Bold.ttf
+        weight: 700
+    - family: Eczar
+      fonts:
+      - asset: packages/rally_assets/Eczar-Regular.ttf
+        weight: 400
+      - asset: packages/rally_assets/Eczar-SemiBold.ttf
+        weight: 600
+      - asset: packages/rally_assets/Eczar-Bold.ttf
+        weight: 700
 
-# PUBSPEC CHECKSUM: 8b43
+# PUBSPEC CHECKSUM: 55a6