[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