| // Copyright 2019 The Flutter team. 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:intl/intl.dart' as intl; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/semantics.dart'; |
| |
| import 'package:gallery/data/gallery_options.dart'; |
| import 'package:gallery/layout/text_scale.dart'; |
| import 'package:gallery/studies/rally/colors.dart'; |
| import 'package:gallery/studies/rally/data.dart'; |
| import 'package:gallery/studies/rally/formatters.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( |
| dateFormat: dateFormatMonthYear(context), |
| numberFormat: usdWithSignFormat(context), |
| events: events, |
| labelStyle: Theme.of(context).textTheme.body1, |
| textDirection: GalleryOptions.of(context).textDirection(), |
| textScaleFactor: reducedTextScale(context), |
| ), |
| ); |
| } |
| } |
| |
| class RallyLineChartPainter extends CustomPainter { |
| RallyLineChartPainter({ |
| @required this.dateFormat, |
| @required this.numberFormat, |
| @required this.events, |
| @required this.labelStyle, |
| @required this.textDirection, |
| @required this.textScaleFactor, |
| }); |
| |
| // The style for the labels. |
| final TextStyle labelStyle; |
| |
| // The text direction for the text. |
| final TextDirection textDirection; |
| |
| // The text scale factor for the text. |
| final double textScaleFactor; |
| |
| // The format for the dates. |
| final intl.DateFormat dateFormat; |
| |
| // The currency format. |
| final intl.NumberFormat numberFormat; |
| |
| // 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 = 2000; // 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 labelHeight = space + space * (textScaleFactor - 1); |
| final ticksHeight = 3 * space; |
| final ticksTop = size.height - labelHeight - ticksHeight - space; |
| final labelsTop = size.height - labelHeight; |
| _drawLine( |
| canvas, |
| Rect.fromLTWH(0, 0, size.width, size.height - labelHeight - ticksHeight), |
| ); |
| _drawXAxisTicks( |
| canvas, |
| Rect.fromLTWH(0, ticksTop, size.width, ticksHeight), |
| ); |
| _drawXAxisLabels( |
| canvas, |
| Rect.fromLTWH(0, labelsTop, size.width, labelHeight), |
| ); |
| } |
| |
| // 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; |
| |
| @override |
| SemanticsBuilderCallback get semanticsBuilder { |
| return (size) { |
| final amounts = _amountsPerDay(numDays); |
| |
| // We divide the graph and the amounts into [numGroups] groups, with |
| // [numItemsPerGroup] amounts per group. |
| final numGroups = 10; |
| final numItemsPerGroup = amounts.length ~/ numGroups; |
| |
| // For each group we calculate the median value. |
| final medians = List.generate( |
| numGroups, |
| (i) { |
| final middleIndex = i * numItemsPerGroup + numItemsPerGroup ~/ 2; |
| if (numItemsPerGroup.isEven) { |
| return (amounts[middleIndex] + amounts[middleIndex + 1]) / 2; |
| } else { |
| return amounts[middleIndex]; |
| } |
| }, |
| ); |
| |
| // Return a list of [CustomPainterSemantics] with the length of |
| // [numGroups], all have the same width with the median amount as label. |
| return List.generate(numGroups, (i) { |
| return CustomPainterSemantics( |
| rect: Offset((i / numGroups) * size.width, 0) & |
| Size(size.width / numGroups, size.height), |
| properties: SemanticsProperties( |
| label: numberFormat.format(medians[i]), |
| textDirection: textDirection, |
| ), |
| ); |
| }); |
| }; |
| } |
| |
| /// Returns the amount of money in the account for the [numDays] given |
| /// from the [startDate]. |
| List<double> _amountsPerDay(int numDays) { |
| // 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 = 600; |
| |
| // Align the points with equal deltas (1 day) as a cumulative sum. |
| int startMillis = startDate.millisecondsSinceEpoch; |
| |
| final amounts = <double>[]; |
| for (var i = 0; i < numDays; i++) { |
| final endMillis = startMillis + millisInDay * 1; |
| final filteredEvents = events.where( |
| (e) { |
| return startMillis <= e.date.millisecondsSinceEpoch && |
| e.date.millisecondsSinceEpoch < endMillis; |
| }, |
| ).toList(); |
| lastAmount += sumOf<DetailedEventData>(filteredEvents, (e) => e.amount); |
| amounts.add(lastAmount); |
| startMillis = endMillis; |
| } |
| return amounts; |
| } |
| |
| void _drawLine(Canvas canvas, Rect rect) { |
| final Paint linePaint = Paint() |
| ..color = RallyColors.accountColor(2) |
| ..style = PaintingStyle.stroke |
| ..strokeWidth = 2; |
| |
| // Try changing this value between 1, 7, 15, etc. |
| const smoothing = 1; |
| |
| final amounts = _amountsPerDay(numDays + smoothing); |
| final points = <Offset>[]; |
| for (int i = 0; i < amounts.length; i++) { |
| final x = i / numDays * rect.width; |
| final y = (maxAmount - amounts[i]) / maxAmount * rect.height; |
| points.add(Offset(x, y)); |
| } |
| |
| // Add last point of the graph to make sure we take up the full width. |
| points.add( |
| Offset( |
| rect.width, |
| (maxAmount - amounts[numDays - 1]) / maxAmount * rect.height, |
| ), |
| ); |
| |
| final path = Path(); |
| path.moveTo(points[0].dx, points[0].dy); |
| for (int i = 1; i < numDays - smoothing + 2; i += smoothing) { |
| final x1 = points[i].dx; |
| final y1 = points[i].dy; |
| final x2 = (x1 + points[i + smoothing].dx) / 2; |
| final 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 selectedLabelStyle = labelStyle.copyWith( |
| fontWeight: FontWeight.w700, |
| fontSize: labelStyle.fontSize * textScaleFactor, |
| ); |
| final unselectedLabelStyle = labelStyle.copyWith( |
| fontWeight: FontWeight.w700, |
| color: RallyColors.gray25, |
| fontSize: labelStyle.fontSize * textScaleFactor, |
| ); |
| |
| // We use toUpperCase to format the dates. This function uses the language |
| // independent Unicode mapping and thus only works in some languages. |
| final leftLabel = TextPainter( |
| text: TextSpan( |
| text: dateFormat.format(startDate).toUpperCase(), |
| style: unselectedLabelStyle, |
| ), |
| textDirection: textDirection, |
| ); |
| leftLabel.layout(); |
| leftLabel.paint(canvas, Offset(rect.left + space / 2, rect.topCenter.dy)); |
| |
| final centerLabel = TextPainter( |
| text: TextSpan( |
| text: dateFormat |
| .format(DateTime(startDate.year, startDate.month + 1)) |
| .toUpperCase(), |
| style: selectedLabelStyle, |
| ), |
| textDirection: textDirection, |
| ); |
| centerLabel.layout(); |
| final x = (rect.width - centerLabel.width) / 2; |
| final y = rect.topCenter.dy; |
| centerLabel.paint(canvas, Offset(x, y)); |
| |
| final rightLabel = TextPainter( |
| text: TextSpan( |
| text: dateFormat |
| .format(DateTime(startDate.year, startDate.month + 2)) |
| .toUpperCase(), |
| style: unselectedLabelStyle, |
| ), |
| textDirection: textDirection, |
| ); |
| rightLabel.layout(); |
| rightLabel.paint( |
| canvas, |
| Offset(rect.right - centerLabel.width - space / 2, rect.topCenter.dy), |
| ); |
| } |
| } |