[Gallery] Add transformations demo (#291)

* Copy over transformations demo with changes

Colors, size and buttons were changed

* Remove <Widget>[]

* Formatting

* Expose gallery theme colors

* Run grinder commands
pull/295/head
Pierre-Louis 5 years ago committed by GitHub
parent fcac28d65d
commit 7b0fdb2ddb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

File diff suppressed because it is too large Load Diff

@ -40,6 +40,7 @@ import 'package:gallery/demos/material/tabs_demo.dart';
import 'package:gallery/demos/material/text_field_demo.dart'; import 'package:gallery/demos/material/text_field_demo.dart';
import 'package:gallery/demos/material/tooltip_demo.dart'; import 'package:gallery/demos/material/tooltip_demo.dart';
import 'package:gallery/demos/reference/colors_demo.dart'; import 'package:gallery/demos/reference/colors_demo.dart';
import 'package:gallery/demos/reference/transformations_demo.dart';
import 'package:gallery/demos/reference/typography_demo.dart'; import 'package:gallery/demos/reference/typography_demo.dart';
import 'package:gallery/l10n/gallery_localizations.dart'; import 'package:gallery/l10n/gallery_localizations.dart';
import 'package:gallery/themes/material_demo_theme_data.dart'; import 'package:gallery/themes/material_demo_theme_data.dart';
@ -807,6 +808,20 @@ List<GalleryDemo> referenceDemos(BuildContext context) {
), ),
], ],
), ),
GalleryDemo(
title: localizations.demo2dTransformationsTitle,
icon: GalleryIcons.gridOn,
subtitle: localizations.demo2dTransformationsSubtitle,
configurations: [
GalleryDemoConfiguration(
title: localizations.demo2dTransformationsTitle,
description: localizations.demo2dTransformationsDescription,
documentationUrl: '$_docsBaseUrl/widgets/GestureDetector-class.html',
buildRoute: (_) => TransformationsDemo(),
code: CodeSegments.transformationsDemo,
),
],
),
]; ];
} }

@ -145,7 +145,7 @@ class _FilterChipDemoState extends State<_FilterChipDemo> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chips = <Widget>[ final chips = [
FilterChip( FilterChip(
label: Text(GalleryLocalizations.of(context).chipElevator), label: Text(GalleryLocalizations.of(context).chipElevator),
selected: isSelectedElevator, selected: isSelectedElevator,

@ -0,0 +1,175 @@
// 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 'dart:ui' show Vertices;
import 'package:flutter/material.dart';
import 'package:gallery/l10n/gallery_localizations.dart';
import 'transformations_demo_board.dart';
import 'transformations_demo_edit_board_point.dart';
import 'transformations_demo_gesture_transformable.dart';
// BEGIN transformationsDemo#1
class TransformationsDemo extends StatefulWidget {
const TransformationsDemo({Key key}) : super(key: key);
@override
_TransformationsDemoState createState() => _TransformationsDemoState();
}
class _TransformationsDemoState extends State<TransformationsDemo> {
// The radius of a hexagon tile in pixels.
static const _kHexagonRadius = 32.0;
// The margin between hexagons.
static const _kHexagonMargin = 1.0;
// The radius of the entire board in hexagons, not including the center.
static const _kBoardRadius = 12;
bool _reset = false;
Board _board = Board(
boardRadius: _kBoardRadius,
hexagonRadius: _kHexagonRadius,
hexagonMargin: _kHexagonMargin,
);
@override
Widget build(BuildContext context) {
final BoardPainter painter = BoardPainter(
board: _board,
);
// The scene is drawn by a CustomPaint, but user interaction is handled by
// the GestureTransformable parent widget.
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.primary,
appBar: AppBar(
automaticallyImplyLeading: false,
title:
Text(GalleryLocalizations.of(context).demo2dTransformationsTitle),
),
body: Container(
color: backgroundColor,
child: LayoutBuilder(
builder: (context, constraints) {
// Draw the scene as big as is available, but allow the user to
// translate beyond that to a visibleSize that's a bit bigger.
final Size size = Size(constraints.maxWidth, constraints.maxHeight);
final Size visibleSize = Size(size.width * 3, size.height * 2);
return GestureTransformable(
reset: _reset,
onResetEnd: () {
setState(() {
_reset = false;
});
},
child: CustomPaint(
painter: painter,
),
boundaryRect: Rect.fromLTWH(
-visibleSize.width / 2,
-visibleSize.height / 2,
visibleSize.width,
visibleSize.height,
),
// Center the board in the middle of the screen. It's drawn centered
// at the origin, which is the top left corner of the
// GestureTransformable.
initialTranslation: Offset(size.width / 2, size.height / 2),
onTapUp: _onTapUp,
size: size,
);
},
),
),
persistentFooterButtons: [resetButton, editButton],
);
}
IconButton get resetButton {
return IconButton(
onPressed: () {
setState(() {
_reset = true;
});
},
tooltip:
GalleryLocalizations.of(context).demo2dTransformationsResetTooltip,
color: Theme.of(context).colorScheme.surface,
icon: const Icon(Icons.replay),
);
}
IconButton get editButton {
return IconButton(
onPressed: () {
if (_board.selected == null) {
return;
}
showModalBottomSheet<Widget>(
context: context,
builder: (context) {
return Container(
width: double.infinity,
height: 150,
padding: const EdgeInsets.all(12),
child: EditBoardPoint(
boardPoint: _board.selected,
onColorSelection: (color) {
setState(() {
_board = _board.copyWithBoardPointColor(
_board.selected, color);
Navigator.pop(context);
});
},
),
);
});
},
tooltip:
GalleryLocalizations.of(context).demo2dTransformationsEditTooltip,
color: Theme.of(context).colorScheme.surface,
icon: const Icon(Icons.edit),
);
}
void _onTapUp(TapUpDetails details) {
final Offset scenePoint = details.globalPosition;
final BoardPoint boardPoint = _board.pointToBoardPoint(scenePoint);
setState(() {
_board = _board.copyWithSelected(boardPoint);
});
}
}
// CustomPainter is what is passed to CustomPaint and actually draws the scene
// when its `paint` method is called.
class BoardPainter extends CustomPainter {
const BoardPainter({
this.board,
});
final Board board;
@override
void paint(Canvas canvas, Size size) {
void drawBoardPoint(BoardPoint boardPoint) {
final Color color = boardPoint.color.withOpacity(
board.selected == boardPoint ? 0.7 : 1,
);
final Vertices vertices =
board.getVerticesForBoardPoint(boardPoint, color);
canvas.drawVertices(vertices, BlendMode.color, Paint());
}
board.forEach(drawBoardPoint);
}
// We should repaint whenever the board changes, such as board.selected.
@override
bool shouldRepaint(BoardPainter oldDelegate) {
return oldDelegate.board != board;
}
}
// END

@ -0,0 +1,289 @@
// 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 'dart:collection' show IterableMixin;
import 'dart:math';
import 'dart:ui' show Vertices;
import 'package:flutter/material.dart' hide Gradient;
import 'package:vector_math/vector_math_64.dart' show Vector3;
// BEGIN transformationsDemo#2
// The entire state of the hex board and abstraction to get information about
// it. Iterable so that all BoardPoints on the board can be iterated over.
@immutable
class Board extends Object with IterableMixin<BoardPoint> {
Board({
@required this.boardRadius,
@required this.hexagonRadius,
@required this.hexagonMargin,
this.selected,
List<BoardPoint> boardPoints,
}) : assert(boardRadius > 0),
assert(hexagonRadius > 0),
assert(hexagonMargin >= 0) {
// Set up the positions for the center hexagon where the entire board is
// centered on the origin.
// Start point of hexagon (top vertex).
final Point<double> hexStart = Point<double>(0, -hexagonRadius);
final double hexagonRadiusPadded = hexagonRadius - hexagonMargin;
final double centerToFlat = sqrt(3) / 2 * hexagonRadiusPadded;
positionsForHexagonAtOrigin.addAll(<Offset>[
Offset(hexStart.x, hexStart.y),
Offset(hexStart.x + centerToFlat, hexStart.y + 0.5 * hexagonRadiusPadded),
Offset(hexStart.x + centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x + centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x, hexStart.y + 2 * hexagonRadiusPadded),
Offset(hexStart.x, hexStart.y + 2 * hexagonRadiusPadded),
Offset(hexStart.x - centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x - centerToFlat, hexStart.y + 1.5 * hexagonRadiusPadded),
Offset(hexStart.x - centerToFlat, hexStart.y + 0.5 * hexagonRadiusPadded),
]);
if (boardPoints != null) {
_boardPoints.addAll(boardPoints);
} else {
// Generate boardPoints for a fresh board.
BoardPoint boardPoint = _getNextBoardPoint(null);
while (boardPoint != null) {
_boardPoints.add(boardPoint);
boardPoint = _getNextBoardPoint(boardPoint);
}
}
}
final int boardRadius; // Number of hexagons from center to edge.
final double hexagonRadius; // Pixel radius of a hexagon (center to vertex).
final double hexagonMargin; // Margin between hexagons.
final List<Offset> positionsForHexagonAtOrigin = <Offset>[];
final BoardPoint selected;
final List<BoardPoint> _boardPoints = <BoardPoint>[];
@override
Iterator<BoardPoint> get iterator => _BoardIterator(_boardPoints);
// For a given q axial coordinate, get the range of possible r values
// See the definition of BoardPoint for more information about hex grids and
// axial coordinates.
_Range _getRRangeForQ(int q) {
int rStart;
int rEnd;
if (q <= 0) {
rStart = -boardRadius - q;
rEnd = boardRadius;
} else {
rEnd = boardRadius - q;
rStart = -boardRadius;
}
return _Range(rStart, rEnd);
}
// Get the BoardPoint that comes after the given BoardPoint. If given null,
// returns the origin BoardPoint. If given BoardPoint is the last, returns
// null.
BoardPoint _getNextBoardPoint(BoardPoint boardPoint) {
// If before the first element.
if (boardPoint == null) {
return BoardPoint(-boardRadius, 0);
}
final _Range rRange = _getRRangeForQ(boardPoint.q);
// If at or after the last element.
if (boardPoint.q >= boardRadius && boardPoint.r >= rRange.max) {
return null;
}
// If wrapping from one q to the next.
if (boardPoint.r >= rRange.max) {
return BoardPoint(boardPoint.q + 1, _getRRangeForQ(boardPoint.q + 1).min);
}
// Otherwise we're just incrementing r.
return BoardPoint(boardPoint.q, boardPoint.r + 1);
}
// Check if the board point is actually on the board.
bool _validateBoardPoint(BoardPoint boardPoint) {
const BoardPoint center = BoardPoint(0, 0);
final int distanceFromCenter = getDistance(center, boardPoint);
return distanceFromCenter <= boardRadius;
}
// Get the distance between two BoardPoins.
static int getDistance(BoardPoint a, BoardPoint b) {
final Vector3 a3 = a.cubeCoordinates;
final Vector3 b3 = b.cubeCoordinates;
return ((a3.x - b3.x).abs() + (a3.y - b3.y).abs() + (a3.z - b3.z).abs()) ~/
2;
}
// Return the q,r BoardPoint for a point in the scene, where the origin is in
// the center of the board in both coordinate systems. If no BoardPoint at the
// location, return null.
BoardPoint pointToBoardPoint(Offset point) {
final BoardPoint boardPoint = BoardPoint(
((sqrt(3) / 3 * point.dx - 1 / 3 * point.dy) / hexagonRadius).round(),
((2 / 3 * point.dy) / hexagonRadius).round(),
);
if (!_validateBoardPoint(boardPoint)) {
return null;
}
return _boardPoints.firstWhere((boardPointI) {
return boardPointI.q == boardPoint.q && boardPointI.r == boardPoint.r;
});
}
// Return a scene point for the center of a hexagon given its q,r point.
Point<double> boardPointToPoint(BoardPoint boardPoint) {
return Point<double>(
sqrt(3) * hexagonRadius * boardPoint.q +
sqrt(3) / 2 * hexagonRadius * boardPoint.r,
1.5 * hexagonRadius * boardPoint.r,
);
}
// Get Vertices that can be drawn to a Canvas for the given BoardPoint.
Vertices getVerticesForBoardPoint(BoardPoint boardPoint, Color color) {
final Point<double> centerOfHexZeroCenter = boardPointToPoint(boardPoint);
final List<Offset> positions = positionsForHexagonAtOrigin.map((offset) {
return offset.translate(centerOfHexZeroCenter.x, centerOfHexZeroCenter.y);
}).toList();
return Vertices(
VertexMode.triangleFan,
positions,
colors: List<Color>.filled(positions.length, color),
);
}
// Return a new board with the given BoardPoint selected.
Board copyWithSelected(BoardPoint boardPoint) {
if (selected == boardPoint) {
return this;
}
final Board nextBoard = Board(
boardRadius: boardRadius,
hexagonRadius: hexagonRadius,
hexagonMargin: hexagonMargin,
selected: boardPoint,
boardPoints: _boardPoints,
);
return nextBoard;
}
// Return a new board where boardPoint has the given color.
Board copyWithBoardPointColor(BoardPoint boardPoint, Color color) {
final BoardPoint nextBoardPoint = boardPoint.copyWithColor(color);
final int boardPointIndex = _boardPoints.indexWhere((boardPointI) =>
boardPointI.q == boardPoint.q && boardPointI.r == boardPoint.r);
if (elementAt(boardPointIndex) == boardPoint && boardPoint.color == color) {
return this;
}
final List<BoardPoint> nextBoardPoints =
List<BoardPoint>.from(_boardPoints);
nextBoardPoints[boardPointIndex] = nextBoardPoint;
final BoardPoint selectedBoardPoint =
boardPoint == selected ? nextBoardPoint : selected;
return Board(
boardRadius: boardRadius,
hexagonRadius: hexagonRadius,
hexagonMargin: hexagonMargin,
selected: selectedBoardPoint,
boardPoints: nextBoardPoints,
);
}
}
class _BoardIterator extends Iterator<BoardPoint> {
_BoardIterator(this.boardPoints);
final List<BoardPoint> boardPoints;
int currentIndex;
@override
BoardPoint current;
@override
bool moveNext() {
if (currentIndex == null) {
currentIndex = 0;
} else {
currentIndex++;
}
if (currentIndex >= boardPoints.length) {
current = null;
return false;
}
current = boardPoints[currentIndex];
return true;
}
}
// A range of q/r board coordinate values.
@immutable
class _Range {
const _Range(this.min, this.max)
: assert(min != null),
assert(max != null),
assert(min <= max);
final int min;
final int max;
}
// A location on the board in axial coordinates.
// Axial coordinates use two integers, q and r, to locate a hexagon on a grid.
// https://www.redblobgames.com/grids/hexagons/#coordinates-axial
@immutable
class BoardPoint {
const BoardPoint(
this.q,
this.r, {
this.color = const Color(0xFFCDCDCD),
});
final int q;
final int r;
final Color color;
@override
String toString() {
return 'BoardPoint($q, $r, $color)';
}
// Only compares by location.
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) {
return false;
}
return other is BoardPoint && other.q == q && other.r == r;
}
@override
int get hashCode => hashValues(q, r);
BoardPoint copyWithColor(Color nextColor) =>
BoardPoint(q, r, color: nextColor);
// Convert from q,r axial coords to x,y,z cube coords.
Vector3 get cubeCoordinates {
return Vector3(
q.toDouble(),
r.toDouble(),
(-q - r).toDouble(),
);
}
}
// END

@ -0,0 +1,76 @@
// 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:flutter/material.dart';
// A generic widget for a list of selectable colors.
@immutable
class ColorPicker extends StatelessWidget {
const ColorPicker({
@required this.colors,
@required this.selectedColor,
this.onColorSelection,
}) : assert(colors != null),
assert(selectedColor != null);
final Set<Color> colors;
final Color selectedColor;
final ValueChanged<Color> onColorSelection;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: colors.map((color) {
return _ColorPickerSwatch(
color: color,
selected: color == selectedColor,
onTap: () {
if (onColorSelection != null) {
onColorSelection(color);
}
},
);
}).toList(),
);
}
}
// A single selectable color widget in the ColorPicker.
@immutable
class _ColorPickerSwatch extends StatelessWidget {
const _ColorPickerSwatch({
@required this.color,
@required this.selected,
this.onTap,
}) : assert(color != null),
assert(selected != null);
final Color color;
final bool selected;
final Function onTap;
@override
Widget build(BuildContext context) {
return Container(
width: 60,
height: 60,
padding: const EdgeInsets.fromLTRB(2, 0, 2, 0),
child: RawMaterialButton(
fillColor: color,
onPressed: () {
if (onTap != null) {
onTap();
}
},
child: !selected
? null
: const Icon(
Icons.check,
color: Colors.white,
),
),
);
}
}

@ -0,0 +1,53 @@
// 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:flutter/material.dart';
import 'package:gallery/themes/gallery_theme_data.dart';
import 'transformations_demo_board.dart';
import 'transformations_demo_color_picker.dart';
final backgroundColor = Color(0xFF272727);
// The panel for editing a board point.
@immutable
class EditBoardPoint extends StatelessWidget {
const EditBoardPoint({
Key key,
@required this.boardPoint,
this.onColorSelection,
}) : assert(boardPoint != null),
super(key: key);
final BoardPoint boardPoint;
final ValueChanged<Color> onColorSelection;
@override
Widget build(BuildContext context) {
print(GalleryThemeData.darkColorScheme);
final boardPointColors = <Color>{
Colors.white,
GalleryThemeData.darkColorScheme.primary,
GalleryThemeData.darkColorScheme.primaryVariant,
GalleryThemeData.darkColorScheme.secondary,
backgroundColor,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${boardPoint.q}, ${boardPoint.r}',
textAlign: TextAlign.right,
style: const TextStyle(fontWeight: FontWeight.bold),
),
ColorPicker(
colors: boardPointColors,
selectedColor: boardPoint.color,
onColorSelection: onColorSelection,
),
],
);
}
}

@ -0,0 +1,620 @@
// 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:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;
import 'transformations_demo_inertial_motion.dart';
// BEGIN transformationsDemo#3
// This widget allows 2D transform interactions on its child in relation to its
// parent. The user can transform the child by dragging to pan or pinching to
// zoom and rotate. All event callbacks for GestureDetector are supported, and
// the coordinates that are given are untransformed and in relation to the
// original position of the child.
@immutable
class GestureTransformable extends StatefulWidget {
const GestureTransformable({
Key key,
// The child to perform the transformations on.
@required this.child,
// The desired visible size of the widget and the area that is receptive to
// gestures. If a widget that's as big as possible is desired, then wrap
// this in a LayoutBuilder and pass
// `Size(constraints.maxWidth, constraints.maxHeight)`.
@required this.size,
// The scale will be clamped to between these values. A maxScale of null has
// no bounds. minScale must be greater than zero.
this.maxScale = 2.5,
this.minScale = 0.8,
// Transforms will be limited so that the viewport can not view beyond this
// Rect. The Rect does not rotate with the rest of the scene, so it is
// always aligned with the viewport. A null boundaryRect results in no
// limits to the distance that the viewport can be transformed to see.
this.boundaryRect,
// Initial values for the transform can be provided.
this.initialTranslation,
this.initialScale,
this.initialRotation,
// Any and all of the possible transformations can be disabled.
this.disableTranslation = false,
this.disableScale = false,
this.disableRotation = false,
// If set to true, this widget will animate back to its initial transform
// and call onResetEnd when done. When utilizing reset, onResetEnd should
// also be implemented, and it should set reset to false when called.
this.reset = false,
// Access to event callbacks from GestureDetector. Called with untransformed
// coordinates in an Offset.
this.onTapDown,
this.onTapUp,
this.onTap,
this.onTapCancel,
this.onDoubleTap,
this.onLongPress,
this.onLongPressUp,
this.onVerticalDragDown,
this.onVerticalDragStart,
this.onVerticalDragUpdate,
this.onVerticalDragEnd,
this.onVerticalDragCancel,
this.onHorizontalDragDown,
this.onHorizontalDragStart,
this.onHorizontalDragUpdate,
this.onHorizontalDragEnd,
this.onHorizontalDragCancel,
this.onPanDown,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd,
this.onPanCancel,
this.onResetEnd,
this.onScaleStart,
this.onScaleUpdate,
this.onScaleEnd,
}) : assert(child != null),
assert(size != null),
assert(minScale != null),
assert(minScale > 0),
assert(disableTranslation != null),
assert(disableScale != null),
assert(disableRotation != null),
assert(reset != null),
assert(
!reset || onResetEnd != null,
'Must implement onResetEnd to use reset.',
),
super(key: key);
final Widget child;
final Size size;
final bool reset;
final GestureTapDownCallback onTapDown;
final GestureTapUpCallback onTapUp;
final GestureTapCallback onTap;
final GestureTapCancelCallback onTapCancel;
final GestureTapCallback onDoubleTap;
final GestureLongPressCallback onLongPress;
final GestureLongPressUpCallback onLongPressUp;
final GestureDragDownCallback onVerticalDragDown;
final GestureDragStartCallback onVerticalDragStart;
final GestureDragUpdateCallback onVerticalDragUpdate;
final GestureDragEndCallback onVerticalDragEnd;
final GestureDragCancelCallback onVerticalDragCancel;
final GestureDragDownCallback onHorizontalDragDown;
final GestureDragStartCallback onHorizontalDragStart;
final GestureDragUpdateCallback onHorizontalDragUpdate;
final GestureDragEndCallback onHorizontalDragEnd;
final GestureDragCancelCallback onHorizontalDragCancel;
final GestureDragDownCallback onPanDown;
final GestureDragStartCallback onPanStart;
final GestureDragUpdateCallback onPanUpdate;
final GestureDragEndCallback onPanEnd;
final GestureDragCancelCallback onPanCancel;
final VoidCallback onResetEnd;
final GestureScaleStartCallback onScaleStart;
final GestureScaleUpdateCallback onScaleUpdate;
final GestureScaleEndCallback onScaleEnd;
final double maxScale;
final double minScale;
final Rect boundaryRect;
final bool disableTranslation;
final bool disableScale;
final bool disableRotation;
final Offset initialTranslation;
final double initialScale;
final double initialRotation;
@override
_GestureTransformableState createState() => _GestureTransformableState();
}
// A single user event can only represent one of these gestures. The user can't
// do multiple at the same time, which results in more precise transformations.
enum _GestureType {
translate,
scale,
rotate,
}
// This is public only for access from a unit test.
class _GestureTransformableState extends State<GestureTransformable>
with TickerProviderStateMixin {
Animation<Offset> _animation;
AnimationController _controller;
Animation<Matrix4> _animationReset;
AnimationController _controllerReset;
// The translation that will be applied to the scene (not viewport).
// A positive x offset moves the scene right, viewport left.
// A positive y offset moves the scene down, viewport up.
Offset _translateFromScene; // Point where a single translation began.
double _scaleStart; // Scale value at start of scaling gesture.
double _rotationStart = 0; // Rotation at start of rotation gesture.
Rect _boundaryRect;
Matrix4 _transform = Matrix4.identity();
double _currentRotation = 0;
_GestureType gestureType;
// The transformation matrix that gives the initial home position.
Matrix4 get _initialTransform {
Matrix4 matrix = Matrix4.identity();
if (widget.initialTranslation != null) {
matrix = matrixTranslate(matrix, widget.initialTranslation);
}
if (widget.initialScale != null) {
matrix = matrixScale(matrix, widget.initialScale);
}
if (widget.initialRotation != null) {
matrix = matrixRotate(matrix, widget.initialRotation, Offset.zero);
}
return matrix;
}
// Return the scene point at the given viewport point.
static Offset fromViewport(Offset viewportPoint, Matrix4 transform) {
// On viewportPoint, perform the inverse transformation of the scene to get
// where the point would be in the scene before the transformation.
final Matrix4 inverseMatrix = Matrix4.inverted(transform);
final Vector3 untransformed = inverseMatrix.transform3(Vector3(
viewportPoint.dx,
viewportPoint.dy,
0,
));
return Offset(untransformed.x, untransformed.y);
}
// Get the offset of the current widget from the global screen coordinates.
// TODO(justinmc): Protect against calling this during first build.
static Offset getOffset(BuildContext context) {
final RenderBox renderObject = context.findRenderObject() as RenderBox;
return renderObject.localToGlobal(Offset.zero);
}
@override
void initState() {
super.initState();
_boundaryRect = widget.boundaryRect ?? Offset.zero & widget.size;
_transform = _initialTransform;
_controller = AnimationController(
vsync: this,
);
_controllerReset = AnimationController(
vsync: this,
);
if (widget.reset) {
_animateResetInitialize();
}
}
@override
void didUpdateWidget(GestureTransformable oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.reset && !oldWidget.reset && _animationReset == null) {
_animateResetInitialize();
} else if (!widget.reset && oldWidget.reset && _animationReset != null) {
_animateResetStop();
}
}
@override
Widget build(BuildContext context) {
// A GestureDetector allows the detection of panning and zooming gestures on
// its child, which is the CustomPaint.
return GestureDetector(
behavior: HitTestBehavior.opaque, // Necessary when translating off screen
onTapDown: widget.onTapDown == null
? null
: (details) {
widget.onTapDown(TapDownDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onTapUp: widget.onTapUp == null
? null
: (details) {
widget.onTapUp(TapUpDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onTap: widget.onTap,
onTapCancel: widget.onTapCancel,
onDoubleTap: widget.onDoubleTap,
onLongPress: widget.onLongPress,
onLongPressUp: widget.onLongPressUp,
onVerticalDragDown: widget.onVerticalDragDown == null
? null
: (details) {
widget.onVerticalDragDown(DragDownDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onVerticalDragStart: widget.onVerticalDragStart == null
? null
: (details) {
widget.onVerticalDragStart(DragStartDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onVerticalDragUpdate: widget.onVerticalDragUpdate == null
? null
: (details) {
widget.onVerticalDragUpdate(DragUpdateDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onVerticalDragEnd: widget.onVerticalDragEnd,
onVerticalDragCancel: widget.onVerticalDragCancel,
onHorizontalDragDown: widget.onHorizontalDragDown == null
? null
: (details) {
widget.onHorizontalDragDown(DragDownDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onHorizontalDragStart: widget.onHorizontalDragStart == null
? null
: (details) {
widget.onHorizontalDragStart(DragStartDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onHorizontalDragUpdate: widget.onHorizontalDragUpdate == null
? null
: (details) {
widget.onHorizontalDragUpdate(DragUpdateDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onHorizontalDragEnd: widget.onHorizontalDragEnd,
onHorizontalDragCancel: widget.onHorizontalDragCancel,
onPanDown: widget.onPanDown == null
? null
: (details) {
widget.onPanDown(DragDownDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onPanStart: widget.onPanStart == null
? null
: (details) {
widget.onPanStart(DragStartDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onPanUpdate: widget.onPanUpdate == null
? null
: (details) {
widget.onPanUpdate(DragUpdateDetails(
globalPosition: fromViewport(
details.globalPosition - getOffset(context), _transform),
));
},
onPanEnd: widget.onPanEnd,
onPanCancel: widget.onPanCancel,
onScaleEnd: _onScaleEnd,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: ClipRect(
// The scene is panned/zoomed/rotated using this Transform widget.
child: Transform(
transform: _transform,
child: Container(
child: widget.child,
height: widget.size.height,
width: widget.size.width,
),
),
),
);
}
// Return a new matrix representing the given matrix after applying the given
// translation.
Matrix4 matrixTranslate(Matrix4 matrix, Offset translation) {
if (widget.disableTranslation || translation == Offset.zero) {
return matrix;
}
// Clamp translation so the viewport remains inside _boundaryRect.
final double scale = _transform.getMaxScaleOnAxis();
final Size scaledSize = widget.size / scale;
final Rect viewportBoundaries = Rect.fromLTRB(
_boundaryRect.left,
_boundaryRect.top,
_boundaryRect.right - scaledSize.width,
_boundaryRect.bottom - scaledSize.height,
);
// Translation is reversed (a positive translation moves the scene to the
// right, viewport to the left).
final Rect translationBoundaries = Rect.fromLTRB(
-scale * viewportBoundaries.right,
-scale * viewportBoundaries.bottom,
-scale * viewportBoundaries.left,
-scale * viewportBoundaries.top,
);
final Matrix4 nextMatrix = matrix.clone()
..translate(
translation.dx,
translation.dy,
);
final Vector3 nextTranslationVector = nextMatrix.getTranslation();
final Offset nextTranslation = Offset(
nextTranslationVector.x,
nextTranslationVector.y,
);
final bool inBoundaries = translationBoundaries.contains(
Offset(nextTranslation.dx, nextTranslation.dy),
);
if (!inBoundaries) {
// TODO(justinmc): Instead of canceling translation when it goes out of
// bounds, stop translation at boundary.
return matrix;
}
return nextMatrix;
}
// Return a new matrix representing the given matrix after applying the given
// scale transform.
Matrix4 matrixScale(Matrix4 matrix, double scale) {
if (widget.disableScale || scale == 1) {
return matrix;
}
assert(scale != 0);
// Don't allow a scale that moves the viewport outside of _boundaryRect.
final Offset tl = fromViewport(const Offset(0, 0), _transform);
final Offset tr = fromViewport(Offset(widget.size.width, 0), _transform);
final Offset bl = fromViewport(Offset(0, widget.size.height), _transform);
final Offset br = fromViewport(
Offset(widget.size.width, widget.size.height),
_transform,
);
if (!_boundaryRect.contains(tl) ||
!_boundaryRect.contains(tr) ||
!_boundaryRect.contains(bl) ||
!_boundaryRect.contains(br)) {
return matrix;
}
// Don't allow a scale that results in an overall scale beyond min/max
// scale.
final double currentScale = _transform.getMaxScaleOnAxis();
final double totalScale = currentScale * scale;
final double clampedTotalScale = totalScale.clamp(
widget.minScale,
widget.maxScale,
) as double;
final double clampedScale = clampedTotalScale / currentScale;
return matrix..scale(clampedScale);
}
// Return a new matrix representing the given matrix after applying the given
// rotation transform.
// Rotating the scene cannot cause the viewport to view beyond _boundaryRect.
Matrix4 matrixRotate(Matrix4 matrix, double rotation, Offset focalPoint) {
if (widget.disableRotation || rotation == 0) {
return matrix;
}
final Offset focalPointScene = fromViewport(focalPoint, matrix);
return matrix
..translate(focalPointScene.dx, focalPointScene.dy)
..rotateZ(-rotation)
..translate(-focalPointScene.dx, -focalPointScene.dy);
}
// Handle the start of a gesture of _GestureType.
void _onScaleStart(ScaleStartDetails details) {
if (widget.onScaleStart != null) {
widget.onScaleStart(details);
}
if (_controller.isAnimating) {
_controller.stop();
_controller.reset();
_animation?.removeListener(_onAnimate);
_animation = null;
}
if (_controllerReset.isAnimating) {
_animateResetStop();
}
gestureType = null;
setState(() {
_scaleStart = _transform.getMaxScaleOnAxis();
_translateFromScene = fromViewport(details.focalPoint, _transform);
_rotationStart = _currentRotation;
});
}
// Handle an update to an ongoing gesture of _GestureType.
void _onScaleUpdate(ScaleUpdateDetails details) {
double scale = _transform.getMaxScaleOnAxis();
if (widget.onScaleUpdate != null) {
widget.onScaleUpdate(ScaleUpdateDetails(
focalPoint: fromViewport(details.focalPoint, _transform),
scale: details.scale,
rotation: details.rotation,
));
}
final Offset focalPointScene = fromViewport(
details.focalPoint,
_transform,
);
if (gestureType == null) {
// Decide which type of gesture this is by comparing the amount of scale
// and rotation in the gesture, if any. Scale starts at 1 and rotation
// starts at 0. Translate will have 0 scale and 0 rotation because it uses
// only one finger.
if ((details.scale - 1).abs() > details.rotation.abs()) {
gestureType = _GestureType.scale;
} else if (details.rotation != 0) {
gestureType = _GestureType.rotate;
} else {
gestureType = _GestureType.translate;
}
}
setState(() {
if (gestureType == _GestureType.scale && _scaleStart != null) {
// details.scale gives us the amount to change the scale as of the
// start of this gesture, so calculate the amount to scale as of the
// previous call to _onScaleUpdate.
final double desiredScale = _scaleStart * details.scale;
final double scaleChange = desiredScale / scale;
_transform = matrixScale(_transform, scaleChange);
scale = _transform.getMaxScaleOnAxis();
// While scaling, translate such that the user's two fingers stay on the
// same places in the scene. That means that the focal point of the
// scale should be on the same place in the scene before and after the
// scale.
final Offset focalPointSceneNext = fromViewport(
details.focalPoint,
_transform,
);
_transform =
matrixTranslate(_transform, focalPointSceneNext - focalPointScene);
} else if (gestureType == _GestureType.rotate && details.rotation != 0) {
final double desiredRotation = _rotationStart + details.rotation;
_transform = matrixRotate(
_transform, _currentRotation - desiredRotation, details.focalPoint);
_currentRotation = desiredRotation;
} else if (_translateFromScene != null && details.scale == 1) {
// Translate so that the same point in the scene is underneath the
// focal point before and after the movement.
final Offset translationChange = focalPointScene - _translateFromScene;
_transform = matrixTranslate(_transform, translationChange);
_translateFromScene = fromViewport(details.focalPoint, _transform);
}
});
}
// Handle the end of a gesture of _GestureType.
void _onScaleEnd(ScaleEndDetails details) {
if (widget.onScaleEnd != null) {
widget.onScaleEnd(details);
}
setState(() {
_scaleStart = null;
_rotationStart = null;
_translateFromScene = null;
});
_animation?.removeListener(_onAnimate);
_controller.reset();
// If the scale ended with velocity, animate inertial movement
final double velocityTotal = details.velocity.pixelsPerSecond.dx.abs() +
details.velocity.pixelsPerSecond.dy.abs();
if (velocityTotal == 0) {
return;
}
final Vector3 translationVector = _transform.getTranslation();
final Offset translation = Offset(translationVector.x, translationVector.y);
final InertialMotion inertialMotion =
InertialMotion(details.velocity, translation);
_animation = Tween<Offset>(
begin: translation,
end: inertialMotion.finalPosition,
).animate(_controller);
_controller.duration =
Duration(milliseconds: inertialMotion.duration.toInt());
_animation.addListener(_onAnimate);
_controller.fling();
}
// Handle inertia drag animation.
void _onAnimate() {
setState(() {
// Translate _transform such that the resulting translation is
// _animation.value.
final Vector3 translationVector = _transform.getTranslation();
final Offset translation =
Offset(translationVector.x, translationVector.y);
final Offset translationScene = fromViewport(translation, _transform);
final Offset animationScene = fromViewport(_animation.value, _transform);
final Offset translationChangeScene = animationScene - translationScene;
_transform = matrixTranslate(_transform, translationChangeScene);
});
if (!_controller.isAnimating) {
_animation?.removeListener(_onAnimate);
_animation = null;
_controller.reset();
}
}
// Handle reset to home transform animation.
void _onAnimateReset() {
setState(() {
_transform = _animationReset.value;
});
if (!_controllerReset.isAnimating) {
_animationReset?.removeListener(_onAnimateReset);
_animationReset = null;
_controllerReset.reset();
widget.onResetEnd();
}
}
// Initialize the reset to home transform animation.
void _animateResetInitialize() {
_controllerReset.reset();
_animationReset = Matrix4Tween(
begin: _transform,
end: _initialTransform,
).animate(_controllerReset);
_controllerReset.duration = const Duration(milliseconds: 400);
_animationReset.addListener(_onAnimateReset);
_controllerReset.forward();
}
// Stop a running reset to home transform animation.
void _animateResetStop() {
_controllerReset.stop();
_animationReset?.removeListener(_onAnimateReset);
_animationReset = null;
_controllerReset.reset();
widget.onResetEnd();
}
@override
void dispose() {
_controller.dispose();
_controllerReset.dispose();
super.dispose();
}
}
// END

@ -0,0 +1,72 @@
// 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 'dart:math';
import 'package:flutter/material.dart';
import 'package:vector_math/vector_math.dart' show Vector2;
// Provides calculations for an object moving with inertia and friction using
// the equation of motion from physics.
// https://en.wikipedia.org/wiki/Equations_of_motion#Constant_translational_acceleration_in_a_straight_line
// TODO(justinmc): Can this be replaced with friction_simulation.dart?
@immutable
class InertialMotion {
const InertialMotion(this._initialVelocity, this._initialPosition);
static const double _kFrictionalAcceleration = 0.01; // How quickly to stop
final Velocity _initialVelocity;
final Offset _initialPosition;
// The position when the motion stops.
Offset get finalPosition {
return _getPositionAt(Duration(milliseconds: duration.toInt()));
}
// The total time that the animation takes start to stop in milliseconds.
double get duration {
return (_initialVelocity.pixelsPerSecond.dx / 1000 / _acceleration.x).abs();
}
// The acceleration opposing the initial velocity in x and y components.
Vector2 get _acceleration {
// TODO(justinmc): Find actual velocity instead of summing?
final velocityTotal = _initialVelocity.pixelsPerSecond.dx.abs() +
_initialVelocity.pixelsPerSecond.dy.abs();
final vRatioX = _initialVelocity.pixelsPerSecond.dx / velocityTotal;
final vRatioY = _initialVelocity.pixelsPerSecond.dy / velocityTotal;
return Vector2(
_kFrictionalAcceleration * vRatioX,
_kFrictionalAcceleration * vRatioY,
);
}
// The position at a given time.
Offset _getPositionAt(Duration time) {
final xf = _getPosition(
r0: _initialPosition.dx,
v0: _initialVelocity.pixelsPerSecond.dx / 1000,
t: time.inMilliseconds,
a: _acceleration.x,
);
final yf = _getPosition(
r0: _initialPosition.dy,
v0: _initialVelocity.pixelsPerSecond.dy / 1000,
t: time.inMilliseconds,
a: _acceleration.y,
);
return Offset(xf, yf);
}
// Solve the equation of motion to find the position at a given point in time
// in one dimension.
double _getPosition({double r0, double v0, int t, double a}) {
// Stop movement when it would otherwise reverse direction.
final stopTime = (v0 / a).abs();
if (t > stopTime) {
t = stopTime.toInt();
}
return r0 + v0 * t + 0.5 * a * pow(t, 2);
}
}

@ -47,7 +47,7 @@ class TypographyDemo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final styleItems = <Widget>[ final styleItems = [
_TextStyleItem( _TextStyleItem(
name: 'Display 4', name: 'Display 4',
style: textTheme.display4, style: textTheme.display4,

@ -153,7 +153,7 @@ class _FeatureDiscoveryState extends State<FeatureDiscovery>
key: FeatureDiscovery.overlayKey, key: FeatureDiscovery.overlayKey,
type: MaterialType.transparency, type: MaterialType.transparency,
child: Stack( child: Stack(
children: <Widget>[ children: [
GestureDetector( GestureDetector(
key: FeatureDiscovery.gestureDetectorKey, key: FeatureDiscovery.gestureDetectorKey,
onTap: dismiss, onTap: dismiss,

@ -220,7 +220,7 @@ class Content extends StatelessWidget {
opacity: opacity, opacity: opacity,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: [
_buildTitle(textTheme), _buildTitle(textTheme),
SizedBox(height: 12.0), SizedBox(height: 12.0),
_buildDescription(textTheme), _buildDescription(textTheme),

File diff suppressed because it is too large Load Diff

@ -1261,6 +1261,26 @@
"@demoTypographyDescription": { "@demoTypographyDescription": {
"description": "Description for the typography demo. Material Design should remain capitalized." "description": "Description for the typography demo. Material Design should remain capitalized."
}, },
"demo2dTransformationsTitle": "2D transformations",
"@demo2dTransformationsTitle": {
"description": "Title for the 2D transformations demo."
},
"demo2dTransformationsSubtitle": "Pan, zoom, rotate",
"@demo2dTransformationsSubtitle": {
"description": "Subtitle for the 2D transformations demo."
},
"demo2dTransformationsDescription": "Tap to edit tiles, and use gestures to move around the scene. Drag to pan, pinch to zoom, rotate with two fingers. Press the reset button to return to the starting orientation.",
"@demo2dTransformationsDescription": {
"description": "Description for the 2D transformations demo."
},
"demo2dTransformationsResetTooltip": "Reset transformations",
"@demo2dTransformationsResetTooltip": {
"description": "Tooltip for a button to reset the transformations (scale, translation) for the 2D transformations demo."
},
"demo2dTransformationsEditTooltip": "Edit tile",
"@demo2dTransformationsEditTooltip": {
"description": "Tooltip for a button to edit a tile."
},
"buttonText": "BUTTON", "buttonText": "BUTTON",
"@buttonText": { "@buttonText": {
"description": "Text for a generic button." "description": "Text for a generic button."

@ -1189,6 +1189,26 @@
name="demoTypographyDescription" name="demoTypographyDescription"
description="Description for the typography demo. Material Design should remain capitalized." description="Description for the typography demo. Material Design should remain capitalized."
>Definitions for the various typographical styles found in Material Design.</string> >Definitions for the various typographical styles found in Material Design.</string>
<string
name="demo2dTransformationsTitle"
description="Title for the 2D transformations demo."
>2D transformations</string>
<string
name="demo2dTransformationsSubtitle"
description="Subtitle for the 2D transformations demo."
>Pan, zoom, rotate</string>
<string
name="demo2dTransformationsDescription"
description="Description for the 2D transformations demo."
>Tap to edit tiles, and use gestures to move around the scene. Drag to pan, pinch to zoom, rotate with two fingers. Press the reset button to return to the starting orientation.</string>
<string
name="demo2dTransformationsResetTooltip"
description="Tooltip for a button to reset the transformations (scale, translation) for the 2D transformations demo."
>Reset transformations</string>
<string
name="demo2dTransformationsEditTooltip"
description="Tooltip for a button to edit a tile."
>Edit tile</string>
<string <string
name="buttonText" name="buttonText"
description="Text for a generic button." description="Text for a generic button."

@ -405,6 +405,16 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Lollipop"), MessageLookupByLibrary.simpleMessage("Lollipop"),
"dataTableRowWithHoney": m29, "dataTableRowWithHoney": m29,
"dataTableRowWithSugar": m30, "dataTableRowWithSugar": m30,
"demo2dTransformationsDescription": MessageLookupByLibrary.simpleMessage(
"Tap to edit tiles, and use gestures to move around the scene. Drag to pan, pinch to zoom, rotate with two fingers. Press the reset button to return to the starting orientation."),
"demo2dTransformationsEditTooltip":
MessageLookupByLibrary.simpleMessage("Edit tile"),
"demo2dTransformationsResetTooltip":
MessageLookupByLibrary.simpleMessage("Reset transformations"),
"demo2dTransformationsSubtitle":
MessageLookupByLibrary.simpleMessage("Pan, zoom, rotate"),
"demo2dTransformationsTitle":
MessageLookupByLibrary.simpleMessage("2D transformations"),
"demoActionChipDescription": MessageLookupByLibrary.simpleMessage( "demoActionChipDescription": MessageLookupByLibrary.simpleMessage(
"Action chips are a set of options which trigger an action related to primary content. Action chips should appear dynamically and contextually in a UI."), "Action chips are a set of options which trigger an action related to primary content. Action chips should appear dynamically and contextually in a UI."),
"demoActionChipTitle": "demoActionChipTitle":

@ -187,15 +187,15 @@ class _SettingsListItemState<T> extends State<SettingsListItem<T>>
_handleExpansion(); _handleExpansion();
final theme = Theme.of(context); final theme = Theme.of(context);
final optionsList = <Widget>[]; final optionWidgetsList = <Widget>[];
widget.options.forEach( widget.options.forEach(
(optionValue, optionDisplay) => optionsList.add( (optionValue, optionDisplay) => optionWidgetsList.add(
RadioListTile<T>( RadioListTile<T>(
value: optionValue, value: optionValue,
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: [
Text( Text(
optionDisplay.title, optionDisplay.title,
style: theme.textTheme.body2.copyWith( style: theme.textTheme.body2.copyWith(
@ -239,8 +239,8 @@ class _SettingsListItemState<T> extends State<SettingsListItem<T>>
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: NeverScrollableScrollPhysics(), physics: NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => optionsList[index], itemBuilder: (context, index) => optionWidgetsList[index],
itemCount: optionsList.length, itemCount: optionWidgetsList.length,
), ),
), ),
); );

@ -12,8 +12,8 @@ class GalleryThemeData {
static Color _darkFocusColor = Colors.white.withOpacity(0.12); static Color _darkFocusColor = Colors.white.withOpacity(0.12);
static ThemeData lightThemeData = static ThemeData lightThemeData =
themeData(_lightColorScheme, _lightFocusColor); themeData(lightColorScheme, _lightFocusColor);
static ThemeData darkThemeData = themeData(_darkColorScheme, _darkFocusColor); static ThemeData darkThemeData = themeData(darkColorScheme, _darkFocusColor);
static ThemeData themeData(ColorScheme colorScheme, Color focusColor) { static ThemeData themeData(ColorScheme colorScheme, Color focusColor) {
return ThemeData( return ThemeData(
@ -43,7 +43,7 @@ class GalleryThemeData {
); );
} }
static ColorScheme _lightColorScheme = ColorScheme( static ColorScheme lightColorScheme = ColorScheme(
primary: const Color(0xFFB93C5D), primary: const Color(0xFFB93C5D),
primaryVariant: const Color(0xFF117378), primaryVariant: const Color(0xFF117378),
secondary: const Color(0xFFEFF3F3), secondary: const Color(0xFFEFF3F3),
@ -59,7 +59,7 @@ class GalleryThemeData {
brightness: Brightness.light, brightness: Brightness.light,
); );
static ColorScheme _darkColorScheme = ColorScheme( static ColorScheme darkColorScheme = ColorScheme(
primary: const Color(0xFFFF8383), primary: const Color(0xFFFF8383),
primaryVariant: const Color(0xFF1CDEC9), primaryVariant: const Color(0xFF1CDEC9),
secondary: const Color(0xFF4D1F7C), secondary: const Color(0xFF4D1F7C),

Loading…
Cancel
Save