Add StarBorder and StarBorder.polygon, with example. (#108489)
diff --git a/dev/bots/analyze.dart b/dev/bots/analyze.dart index f3543fb..b7ff8b6 100644 --- a/dev/bots/analyze.dart +++ b/dev/bots/analyze.dart
@@ -412,7 +412,7 @@ if (needsTag) { if (!hasTagNotation) { errors.add('${file.path}: Files containing golden tests must be tagged using ' - '`@Tags(...)` at the top of the file before import statements.'); + "@Tags(<String>['reduced-test-set']) at the top of the file before import statements."); } else if (!hasReducedTag) { errors.add('${file.path}: Files containing golden tests must be tagged with ' "'reduced-test-set'.");
diff --git a/dev/bots/test/analyze_test.dart b/dev/bots/test/analyze_test.dart index b38609d..b5c747f 100644 --- a/dev/bots/test/analyze_test.dart +++ b/dev/bots/test/analyze_test.dart
@@ -74,10 +74,9 @@ test('analyze.dart - verifyGoldenTags', () async { final String result = await capture(() => verifyGoldenTags(testRootPath, minimumMatches: 6), exitCode: 1); - const String noTag = 'Files containing golden tests must be ' - 'tagged using `@Tags(...)` at the top of the file before import statements.'; - const String missingTag = 'Files containing golden tests must be ' - "tagged with 'reduced-test-set'."; + const String noTag = "Files containing golden tests must be tagged using @Tags(<String>['reduced-test-set']) " + 'at the top of the file before import statements.'; + const String missingTag = "Files containing golden tests must be tagged with 'reduced-test-set'."; String lines = <String>[ 'test/analyze-test-input/root/packages/foo/golden_missing_tag.dart: $missingTag', 'test/analyze-test-input/root/packages/foo/golden_no_tag.dart: $noTag',
diff --git a/dev/manual_tests/lib/star_border.dart b/dev/manual_tests/lib/star_border.dart new file mode 100644 index 0000000..6286af6 --- /dev/null +++ b/dev/manual_tests/lib/star_border.dart
@@ -0,0 +1,555 @@ +// Copyright 2014 The Flutter 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/foundation.dart'; +import 'package:flutter/material.dart'; + +void main() => runApp(const MyApp()); + +enum LerpTarget { + circle, + roundedRect, + rect, + stadium, + polygon, + star, +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State<MyHomePage> createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State<MyHomePage> { + static final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>(); + final OptionModel _model = OptionModel(); + final TextEditingController textController = TextEditingController(); + + @override + void initState() { + super.initState(); + _model.addListener(_modelChanged); + } + + @override + void dispose() { + super.dispose(); + _model.removeListener(_modelChanged); + } + + void _modelChanged() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + key: scaffoldKey, + appBar: AppBar( + title: const Text('Star Border'), + backgroundColor: const Color(0xff323232), + ), + body: Column( + children: <Widget>[ + Container(color: Colors.grey.shade200, child: Options(_model)), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: <Widget>[ + Container( + key: UniqueKey(), + alignment: Alignment.center, + width: 300, + height: 200, + decoration: ShapeDecoration( + color: Colors.blue.shade100, + shape: lerpBorder( + StarBorder.polygon( + side: const BorderSide(strokeAlign: StrokeAlign.center, width: 2), + sides: _model.points, + pointRounding: _model.pointRounding, + rotation: _model.rotation, + squash: _model.squash, + ), + _model._lerpTarget, + _model._lerpAmount, + to: _model.lerpTo, + )!, + ), + child: const Text('Polygon'), + ), + Container( + key: UniqueKey(), + alignment: Alignment.center, + width: 300, + height: 200, + decoration: ShapeDecoration( + color: Colors.blue.shade100, + shape: lerpBorder( + StarBorder( + side: const BorderSide(strokeAlign: StrokeAlign.center, width: 2), + points: _model.points, + innerRadiusRatio: _model.innerRadiusRatio, + pointRounding: _model.pointRounding, + valleyRounding: _model.valleyRounding, + rotation: _model.rotation, + squash: _model.squash, + ), + _model._lerpTarget, + _model._lerpAmount, + to: _model.lerpTo, + )!, + ), + child: const Text('Star'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class OptionModel extends ChangeNotifier { + double get pointRounding => _pointRounding; + double _pointRounding = 0.0; + set pointRounding(double value) { + if (value != _pointRounding) { + _pointRounding = value; + if (_valleyRounding + _pointRounding > 1) { + _valleyRounding = 1.0 - _pointRounding; + } + notifyListeners(); + } + } + + double get valleyRounding => _valleyRounding; + double _valleyRounding = 0.0; + set valleyRounding(double value) { + if (value != _valleyRounding) { + _valleyRounding = value; + if (_valleyRounding + _pointRounding > 1) { + _pointRounding = 1.0 - _valleyRounding; + } + notifyListeners(); + } + } + + double get squash => _squash; + double _squash = 0.0; + set squash(double value) { + if (value != _squash) { + _squash = value; + notifyListeners(); + } + } + + double get rotation => _rotation; + double _rotation = 0.0; + set rotation(double value) { + if (value != _rotation) { + _rotation = value; + notifyListeners(); + } + } + + double get innerRadiusRatio => _innerRadiusRatio; + double _innerRadiusRatio = 0.4; + set innerRadiusRatio(double value) { + if (value != _innerRadiusRatio) { + _innerRadiusRatio = clampDouble(value, 0.0001, double.infinity); + notifyListeners(); + } + } + + double get points => _points; + double _points = 5; + set points(double value) { + if (value != _points) { + _points = value; + notifyListeners(); + } + } + + double get lerpAmount => _lerpAmount; + double _lerpAmount = 0.0; + set lerpAmount(double value) { + if (value != _lerpAmount) { + _lerpAmount = value; + notifyListeners(); + } + } + + bool get lerpTo => _lerpTo; + bool _lerpTo = true; + set lerpTo(bool value) { + if (_lerpTo != value) { + _lerpTo = value; + notifyListeners(); + } + } + + LerpTarget get lerpTarget => _lerpTarget; + LerpTarget _lerpTarget = LerpTarget.circle; + set lerpTarget(LerpTarget value) { + if (value != _lerpTarget) { + _lerpTarget = value; + notifyListeners(); + } + } + + void reset() { + final OptionModel defaultModel = OptionModel(); + _pointRounding = defaultModel.pointRounding; + _valleyRounding = defaultModel.valleyRounding; + _rotation = defaultModel.rotation; + _squash = defaultModel.squash; + _lerpAmount = defaultModel.lerpAmount; + _lerpTo = defaultModel.lerpTo; + _lerpTarget = defaultModel.lerpTarget; + _innerRadiusRatio = defaultModel._innerRadiusRatio; + _points = defaultModel.points; + notifyListeners(); + } +} + +class LabeledCheckbox extends StatelessWidget { + const LabeledCheckbox({super.key, required this.label, this.onChanged, this.value}); + + final String label; + final ValueChanged<bool?>? onChanged; + final bool? value; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Checkbox( + onChanged: onChanged, + value: value, + ), + Text(label), + ], + ); + } +} + +class Options extends StatefulWidget { + const Options(this.model, {super.key}); + + final OptionModel model; + + @override + State<Options> createState() => _OptionsState(); +} + +class _OptionsState extends State<Options> { + @override + void initState() { + super.initState(); + widget.model.addListener(_modelChanged); + } + + @override + void didUpdateWidget(Options oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.model != oldWidget.model) { + oldWidget.model.removeListener(_modelChanged); + widget.model.addListener(_modelChanged); + } + } + + @override + void dispose() { + super.dispose(); + widget.model.removeListener(_modelChanged); + } + + void _modelChanged() { + setState(() {}); + } + + double sliderValue = 0.0; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(5.0, 0.0, 5.0, 10.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: ControlSlider( + label: 'Point Rounding', + value: widget.model.pointRounding, + onChanged: (double value) { + widget.model.pointRounding = value; + }, + ), + ), + Expanded( + child: ControlSlider( + label: 'Valley Rounding', + value: widget.model.valleyRounding, + onChanged: (double value) { + widget.model.valleyRounding = value; + }, + ), + ), + ], + ), + Row( + children: <Widget>[ + Expanded( + child: ControlSlider( + label: 'Squash', + value: widget.model.squash, + onChanged: (double value) { + widget.model.squash = value; + }, + ), + ), + Expanded( + child: ControlSlider( + label: 'Rotation', + value: widget.model.rotation, + max: 360, + onChanged: (double value) { + widget.model.rotation = value; + }, + ), + ), + ], + ), + Row( + children: <Widget>[ + Expanded( + child: Row( + children: <Widget>[ + Expanded( + child: ControlSlider( + label: 'Points', + value: widget.model.points, + min: 2, + max: 20, + onChanged: (double value) { + widget.model.points = value; + }, + ), + ), + OutlinedButton( + child: const Text('Nearest'), + onPressed: () { + widget.model.points = widget.model.points.roundToDouble(); + }), + ], + ), + ), + Expanded( + child: ControlSlider( + label: 'Inner Radius', + value: widget.model.innerRadiusRatio, + onChanged: (double value) { + widget.model.innerRadiusRatio = value; + }, + ), + ), + ], + ), + Row( + children: <Widget>[ + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 8.0), + child: ControlSlider( + label: 'Lerp', + value: widget.model.lerpAmount, + onChanged: (double value) { + widget.model.lerpAmount = value; + }, + ), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only(start: 8.0, end: 20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Row(children: <Widget>[ + Radio<bool>( + value: true, + groupValue: widget.model.lerpTo, + onChanged: (bool? value) { + widget.model.lerpTo = value!; + }), + const Text('To'), + ]), + Row(children: <Widget>[ + Radio<bool>( + value: false, + groupValue: widget.model.lerpTo, + onChanged: (bool? value) { + widget.model.lerpTo = value!; + }), + const Text('From'), + ]) + ], + ), + ), + Expanded( + child: Row( + children: <Widget>[ + Expanded( + child: DropdownButton<LerpTarget>( + items: LerpTarget.values.map<DropdownMenuItem<LerpTarget>>((LerpTarget target) { + return DropdownMenuItem<LerpTarget>(value: target, child: Text(target.name)); + }).toList(), + value: widget.model.lerpTarget, + onChanged: (LerpTarget? value) { + if (value == null) { + return; + } + widget.model.lerpTarget = value; + }, + ), + ), + ], + ), + ), + ], + ), + ElevatedButton( + onPressed: () { + widget.model.reset(); + sliderValue = 0.0; + }, + child: const Text('Reset'), + ), + ], + ), + ); + } +} + +class ControlSlider extends StatelessWidget { + const ControlSlider({ + super.key, + required this.label, + required this.value, + required this.onChanged, + this.min = 0.0, + this.max = 1.0, + }); + + final String label; + final double value; + final void Function(double value) onChanged; + final double min; + final double max; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Text(label), + Expanded( + child: Slider( + label: value.toStringAsFixed(1), + onChanged: onChanged, + min: min, + max: max, + value: value, + ), + ), + Text( + value.toStringAsFixed(3), + ), + ], + ), + ); + } +} + +const Color lerpToColor = Colors.red; +const BorderSide lerpToBorder = BorderSide(width: 5, color: lerpToColor); + +ShapeBorder? lerpBorder(StarBorder border, LerpTarget target, double t, {bool to = true}) { + switch (target) { + case LerpTarget.circle: + if (to) { + return border.lerpTo(const CircleBorder(side: lerpToBorder), t); + } else { + return border.lerpFrom(const CircleBorder(side: lerpToBorder), t); + } + case LerpTarget.roundedRect: + if (to) { + return border.lerpTo( + const RoundedRectangleBorder( + side: lerpToBorder, + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), + t, + ); + } else { + return border.lerpFrom( + const RoundedRectangleBorder( + side: lerpToBorder, + borderRadius: BorderRadius.all( + Radius.circular(10), + ), + ), + t, + ); + } + case LerpTarget.rect: + if (to) { + return border.lerpTo(const RoundedRectangleBorder(side: lerpToBorder), t); + } else { + return border.lerpFrom(const RoundedRectangleBorder(side: lerpToBorder), t); + } + case LerpTarget.stadium: + if (to) { + return border.lerpTo(const StadiumBorder(side: lerpToBorder), t); + } else { + return border.lerpFrom(const StadiumBorder(side: lerpToBorder), t); + } + case LerpTarget.polygon: + if (to) { + return border.lerpTo(const StarBorder.polygon(side: lerpToBorder, sides: 4), t); + } else { + return border.lerpFrom(const StarBorder.polygon(side: lerpToBorder, sides: 4), t); + } + case LerpTarget.star: + if (to) { + return border.lerpTo(const StarBorder(side: lerpToBorder, innerRadiusRatio: .5), t); + } else { + return border.lerpFrom(const StarBorder(side: lerpToBorder, innerRadiusRatio: .5), t); + } + } +}
diff --git a/examples/api/lib/painting/star_border/star_border.0.dart b/examples/api/lib/painting/star_border/star_border.0.dart new file mode 100644 index 0000000..d2cb175 --- /dev/null +++ b/examples/api/lib/painting/star_border/star_border.0.dart
@@ -0,0 +1,454 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// An example showing usage of [StarBorder]. + +import 'package:flutter/material.dart'; + +const int _kParameterPrecision = 2; + +void main() => runApp(const StarBorderApp()); + +class StarBorderApp extends StatelessWidget { + const StarBorderApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('StarBorder Example'), + backgroundColor: const Color(0xff323232), + ), + body: const StarBorderExample(), + ), + ); + } +} + +class StarBorderExample extends StatefulWidget { + const StarBorderExample({super.key}); + + @override + State<StarBorderExample> createState() => _StarBorderExampleState(); +} + +class _StarBorderExampleState extends State<StarBorderExample> { + final OptionModel _model = OptionModel(); + final TextEditingController _textController = TextEditingController(); + + @override + void initState() { + super.initState(); + _model.addListener(_modelChanged); + } + + @override + void dispose() { + _model.removeListener(_modelChanged); + _textController.dispose(); + super.dispose(); + } + + void _modelChanged() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return DefaultTextStyle( + style: const TextStyle( + color: Colors.black, + fontSize: 14.0, + fontFamily: 'Roboto', + fontStyle: FontStyle.normal, + ), + child: ListView( + children: <Widget>[ + Container( + color: Colors.grey.shade200, + child: Options(_model), + ), + Padding( + padding: const EdgeInsets.all(18.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: <Widget>[ + Expanded( + child: ExampleBorder( + border: StarBorder( + side: const BorderSide(), + points: _model.points, + innerRadiusRatio: _model.innerRadiusRatio, + pointRounding: _model.pointRounding, + valleyRounding: _model.valleyRounding, + rotation: _model.rotation, + squash: _model.squash, + ), + title: 'Star', + ), + ), + Expanded( + child: ExampleBorder( + border: StarBorder.polygon( + side: const BorderSide(), + sides: _model.points, + pointRounding: _model.pointRounding, + rotation: _model.rotation, + squash: _model.squash, + ), + title: 'Polygon', + ), + ), + ], + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: <Widget>[ + Expanded( + child: Container( + color: Colors.black12, + margin: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16.0), + child: SelectableText(_model.starCode), + ), + ), + Expanded( + child: Container( + color: Colors.black12, + margin: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16.0), + child: SelectableText(_model.polygonCode), + ), + ), + ], + ), + ], + ), + ); + } +} + +class ExampleBorder extends StatelessWidget { + const ExampleBorder({ + super.key, + required this.border, + required this.title, + }); + + final StarBorder border; + final String title; + + @override + Widget build(BuildContext context) { + return Container( + key: UniqueKey(), + alignment: Alignment.center, + padding: const EdgeInsets.all(20), + width: 150, + height: 100, + decoration: ShapeDecoration( + color: Colors.blue.shade100, + shape: border, + ), + child: Text(title), + ); + } +} + +class Options extends StatefulWidget { + const Options(this.model, {super.key}); + + final OptionModel model; + + @override + State<Options> createState() => _OptionsState(); +} + +class _OptionsState extends State<Options> { + @override + void initState() { + super.initState(); + widget.model.addListener(_modelChanged); + } + + @override + void didUpdateWidget(Options oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.model != oldWidget.model) { + oldWidget.model.removeListener(_modelChanged); + widget.model.addListener(_modelChanged); + } + } + + @override + void dispose() { + super.dispose(); + widget.model.removeListener(_modelChanged); + } + + void _modelChanged() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(5.0, 0.0, 5.0, 10.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Row( + children: <Widget>[ + Expanded( + child: ControlSlider( + label: 'Point Rounding', + value: widget.model.pointRounding, + onChanged: (double value) { + widget.model.pointRounding = value; + }, + ), + ), + Expanded( + child: ControlSlider( + label: 'Valley Rounding', + value: widget.model.valleyRounding, + onChanged: (double value) { + widget.model.valleyRounding = value; + }, + ), + ), + ], + ), + Row( + children: <Widget>[ + Expanded( + child: ControlSlider( + label: 'Squash', + value: widget.model.squash, + onChanged: (double value) { + widget.model.squash = value; + }, + ), + ), + Expanded( + child: ControlSlider( + label: 'Rotation', + value: widget.model.rotation, + max: 360, + onChanged: (double value) { + widget.model.rotation = value; + }, + ), + ), + ], + ), + Row( + children: <Widget>[ + Expanded( + child: Row( + children: <Widget>[ + Expanded( + child: ControlSlider( + label: 'Points', + value: widget.model.points, + min: 3, + max: 20, + precision: 1, + onChanged: (double value) { + widget.model.points = value; + }, + ), + ), + Tooltip( + message: 'Round the number of points to the nearest integer.', + child: Padding( + padding: const EdgeInsets.all(8.0), + child: OutlinedButton( + child: const Text('Nearest'), + onPressed: () { + widget.model.points = widget.model.points.roundToDouble(); + }, + ), + ), + ), + ], + ), + ), + Expanded( + child: ControlSlider( + label: 'Inner Radius', + value: widget.model.innerRadiusRatio, + onChanged: (double value) { + widget.model.innerRadiusRatio = value; + }, + ), + ), + ], + ), + ElevatedButton( + onPressed: () { + widget.model.reset(); + }, + child: const Text('Reset'), + ), + ], + ), + ); + } +} + +class OptionModel extends ChangeNotifier { + double get pointRounding => _pointRounding; + double _pointRounding = 0.0; + set pointRounding(double value) { + if (value != _pointRounding) { + _pointRounding = value; + if (_valleyRounding + _pointRounding > 1) { + _valleyRounding = 1.0 - _pointRounding; + } + notifyListeners(); + } + } + + double get valleyRounding => _valleyRounding; + double _valleyRounding = 0.0; + set valleyRounding(double value) { + if (value != _valleyRounding) { + _valleyRounding = value; + if (_valleyRounding + _pointRounding > 1) { + _pointRounding = 1.0 - _valleyRounding; + } + notifyListeners(); + } + } + + double get squash => _squash; + double _squash = 0.0; + set squash(double value) { + if (value != _squash) { + _squash = value; + notifyListeners(); + } + } + + double get rotation => _rotation; + double _rotation = 0.0; + set rotation(double value) { + if (value != _rotation) { + _rotation = value; + notifyListeners(); + } + } + + double get innerRadiusRatio => _innerRadiusRatio; + double _innerRadiusRatio = 0.4; + set innerRadiusRatio(double value) { + if (value != _innerRadiusRatio) { + _innerRadiusRatio = value.clamp(0.0001, double.infinity); + notifyListeners(); + } + } + + double get points => _points; + double _points = 5; + set points(double value) { + if (value != _points) { + _points = value; + notifyListeners(); + } + } + + String get starCode { + return 'Container(\n' + ' decoration: ShapeDecoration(\n' + ' shape: StarBorder(\n' + ' points: ${points.toStringAsFixed(_kParameterPrecision)},\n' + ' rotation: ${rotation.toStringAsFixed(_kParameterPrecision)},\n' + ' innerRadiusRatio: ${innerRadiusRatio.toStringAsFixed(_kParameterPrecision)},\n' + ' pointRounding: ${pointRounding.toStringAsFixed(_kParameterPrecision)},\n' + ' valleyRounding: ${valleyRounding.toStringAsFixed(_kParameterPrecision)},\n' + ' squash: ${squash.toStringAsFixed(_kParameterPrecision)},\n' + ' ),\n' + ' ),\n' + ');'; + } + + String get polygonCode { + return 'Container(\n' + ' decoration: ShapeDecoration(\n' + ' shape: StarBorder.polygon(\n' + ' sides: ${points.toStringAsFixed(_kParameterPrecision)},\n' + ' rotation: ${rotation.toStringAsFixed(_kParameterPrecision)},\n' + ' cornerRounding: ${pointRounding.toStringAsFixed(_kParameterPrecision)},\n' + ' squash: ${squash.toStringAsFixed(_kParameterPrecision)},\n' + ' ),\n' + ' ),\n' + ');'; + } + + void reset() { + final OptionModel defaultModel = OptionModel(); + _pointRounding = defaultModel.pointRounding; + _valleyRounding = defaultModel.valleyRounding; + _rotation = defaultModel.rotation; + _squash = defaultModel.squash; + _innerRadiusRatio = defaultModel._innerRadiusRatio; + _points = defaultModel.points; + notifyListeners(); + } +} + +class ControlSlider extends StatelessWidget { + const ControlSlider({ + super.key, + required this.label, + required this.value, + required this.onChanged, + this.min = 0.0, + this.max = 1.0, + this.precision = _kParameterPrecision, + }); + + final String label; + final double value; + final void Function(double value) onChanged; + final double min; + final double max; + final int precision; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: <Widget>[ + Expanded( + flex: 2, + child: Text( + label, + textAlign: TextAlign.end, + ), + ), + Expanded( + flex: 5, + child: Slider( + onChanged: onChanged, + min: min, + max: max, + value: value, + ), + ), + Expanded( + child: Text( + value.toStringAsFixed(precision), + ), + ), + ], + ), + ); + } +}
diff --git a/packages/flutter/lib/painting.dart b/packages/flutter/lib/painting.dart index 33dc9da..ce86d81 100644 --- a/packages/flutter/lib/painting.dart +++ b/packages/flutter/lib/painting.dart
@@ -56,6 +56,7 @@ export 'src/painting/shader_warm_up.dart'; export 'src/painting/shape_decoration.dart'; export 'src/painting/stadium_border.dart'; +export 'src/painting/star_border.dart'; export 'src/painting/strut_style.dart'; export 'src/painting/text_painter.dart'; export 'src/painting/text_span.dart';
diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index e3b3c4e..1a5b1ab 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart
@@ -144,7 +144,8 @@ assert(min != null), assert(max != null), assert(min <= max), - assert(value >= min && value <= max), + assert(value >= min && value <= max, + 'Value $value is not between minimum $min and maximum $max'), assert(divisions == null || divisions > 0); /// Creates an adaptive [Slider] based on the target platform, following
diff --git a/packages/flutter/lib/src/painting/star_border.dart b/packages/flutter/lib/src/painting/star_border.dart new file mode 100644 index 0000000..c5c20b7 --- /dev/null +++ b/packages/flutter/lib/src/painting/star_border.dart
@@ -0,0 +1,698 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui' as ui show lerpDouble; + +import 'package:flutter/foundation.dart'; + +import 'package:vector_math/vector_math_64.dart' show Matrix4; +import 'basic_types.dart'; +import 'borders.dart'; +import 'circle_border.dart'; +import 'edge_insets.dart'; +import 'rounded_rectangle_border.dart'; +import 'stadium_border.dart'; + +// Conversion from radians to degrees. +const double _kRadToDeg = 180 / math.pi; +// Conversion from degrees to radians. +const double _kDegToRad = math.pi / 180; + +/// A border that fits a star or polygon-shaped border within the rectangle of +/// the widget it is applied to. +/// +/// Typically used with a [ShapeDecoration] to draw a polygonal or star shaped +/// border. +/// +/// {@tool dartpad} +/// This example serves both as a usage example, as well as an explorer for +/// determining the parameters to use with a [StarBorder]. The resulting code +/// can be copied and pasted into your app. A [Container] is just one widget +/// which takes a [ShapeBorder]. [Dialog]s, [OutlinedButton]s, +/// [ElevatedButton]s, etc. all can be shaped with a [ShapeBorder]. +/// +/// ** See code in examples/api/lib/painting/star_border/star_border.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [BorderSide], which is used to describe how the edge of the shape is +/// drawn. +class StarBorder extends OutlinedBorder { + /// Create a const star-shaped border with the given number [points] on the + /// star. + const StarBorder({ + super.side, + this.points = 5, + double innerRadiusRatio = 0.4, + this.pointRounding = 0, + this.valleyRounding = 0, + double rotation = 0, + this.squash = 0, + }) : assert(squash >= 0), + assert(squash <= 1), + assert(pointRounding >= 0), + assert(pointRounding <= 1), + assert(valleyRounding >= 0), + assert(valleyRounding <= 1), + assert( + (valleyRounding + pointRounding) <= 1, + 'The sum of valleyRounding ($valleyRounding) and ' + 'pointRounding ($pointRounding) must not exceed one.'), + assert(innerRadiusRatio >= 0), + assert(innerRadiusRatio <= 1), + assert(points >= 2), + _rotationRadians = rotation * _kDegToRad, + _innerRadiusRatio = innerRadiusRatio; + + /// Create a const polygon border with the given number of [sides]. + const StarBorder.polygon({ + super.side, + double sides = 5, + this.pointRounding = 0, + double rotation = 0, + this.squash = 0, + }) : assert(squash >= 0), + assert(squash <= 1), + assert(pointRounding >= 0), + assert(pointRounding <= 1), + assert(sides >= 2), + points = sides, + valleyRounding = 0, + _rotationRadians = rotation * _kDegToRad, + _innerRadiusRatio = null; + + /// The number of points in this star, or sides on a polygon. + /// + /// This is a floating point number: if this is not a whole number, then an + /// additional star point or corner shorter than the others will be added to + /// finish the shape. Only whole-numbered values will yield a symmetric shape. + /// + /// For stars created with [StarBorder], this the number of points on + /// the star. For polygons created with [StarBorder.polygon], this is the + /// number of sides on the polygon. + /// + /// Must be greater than or equal to two. + final double points; + + /// The ratio of the inner radius of a star with the outer radius. + /// + /// When making a star using [StarBorder], this is the ratio of the inner + /// radius that to the outer radius. If it is one, then the inner radius + /// will equal the outer radius. + /// + /// For polygons created with [StarBorder.polygon], getting this value will + /// return the incircle radius of the polygon (the radius of a circle + /// inscribed inside the polygon). + /// + /// Defaults to 0.4 for stars, and must be between zero and one, inclusive. + double get innerRadiusRatio { + // Polygons are just a special case of a star where the inner radius is the + // incircle radius of the polygon (the radius of an inscribed circle). + return _innerRadiusRatio ?? math.cos(math.pi / points); + } + + final double? _innerRadiusRatio; + + /// The amount of rounding on the points of stars, or the corners of polygons. + /// + /// This is a value between zero and one which describes how rounded the point + /// or corner should be. A value of zero means no rounding (sharp corners), + /// and a value of one means that the entire point or corner is a portion of a + /// circle. + /// + /// Defaults to zero. The sum of `pointRounding` and [valleyRounding] must be + /// less than or equal to one. + final double pointRounding; + + /// The amount of rounding of the interior corners of stars. + /// + /// This is a value between zero and one which describes how rounded the inner + /// corners in a star (the "valley" between points) should be. A value of zero + /// means no rounding (sharp corners), and a value of one means that the + /// entire corner is a portion of a circle. + /// + /// Defaults to zero. The sum of [pointRounding] and `valleyRounding` must be + /// less than or equal to one. For polygons created with [StarBorder.polygon], + /// this will always be zero. + final double valleyRounding; + + /// The rotation in clockwise degrees around the center of the shape. + /// + /// The rotation occurs before the [squash] effect is applied, so that you can + /// fine tune where the points of a star or corners of a polygon start. + /// + /// Defaults to zero, meaning that the first point or corner is pointing up. + double get rotation => _rotationRadians * _kRadToDeg; + final double _rotationRadians; + + /// How much of the aspect ratio of the attached widget to take on. + /// + /// If `squash` is non-zero, the border will match the aspect ratio of the + /// bounding box of the widget that it is attached to, which can give a + /// squashed appearance. + /// + /// The `squash` parameter lets you control how much of that aspect ratio this + /// border takes on. + /// + /// A value of zero means that the border will be drawn with a square aspect + /// ratio at the size of the shortest side of the bounding rectangle, ignoring + /// the aspect ratio of the widget, and a value of one means it will be drawn + /// with the aspect ratio of the widget. The value of `squash` has no effect + /// if the widget is square to begin with. + /// + /// Defaults to zero, and must be between zero and one, inclusive. + final double squash; + + @override + EdgeInsetsGeometry get dimensions { + switch (side.strokeAlign) { + case StrokeAlign.inside: + return EdgeInsets.all(side.width); + case StrokeAlign.center: + return EdgeInsets.all(side.width / 2); + case StrokeAlign.outside: + return EdgeInsets.zero; + } + } + + @override + ShapeBorder scale(double t) { + return StarBorder( + points: points, + side: side.scale(t), + rotation: rotation, + innerRadiusRatio: innerRadiusRatio, + pointRounding: pointRounding, + valleyRounding: valleyRounding, + squash: squash, + ); + } + + ShapeBorder? _twoPhaseLerp( + double t, + double split, + ShapeBorder? Function(double t) first, + ShapeBorder? Function(double t) second, + ) { + // If the rectangle has square corners, then skip the extra lerp to round the corners. + if (t < split) { + return first(t * (1 / split)); + } else { + t = (1 / (1.0 - split)) * (t - split); + return second(t); + } + } + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) { + if (t == 0) { + return a; + } + if (t == 1.0) { + return this; + } + if (a is StarBorder) { + return StarBorder( + side: BorderSide.lerp(a.side, side, t), + points: ui.lerpDouble(a.points, points, t)!, + rotation: ui.lerpDouble(a._rotationRadians, _rotationRadians, t)! * _kRadToDeg, + innerRadiusRatio: ui.lerpDouble(a.innerRadiusRatio, innerRadiusRatio, t)!, + pointRounding: ui.lerpDouble(a.pointRounding, pointRounding, t)!, + valleyRounding: ui.lerpDouble(a.valleyRounding, valleyRounding, t)!, + squash: ui.lerpDouble(a.squash, squash, t)!, + ); + } + + if (a is CircleBorder) { + if (points >= 2.5) { + final double lerpedPoints = ui.lerpDouble(points.round(), points, t)!; + return StarBorder( + side: BorderSide.lerp(a.side, side, t), + points: lerpedPoints, + rotation: rotation, + innerRadiusRatio: ui.lerpDouble(math.cos(math.pi / lerpedPoints), innerRadiusRatio, t)!, + pointRounding: ui.lerpDouble(1.0, pointRounding, t)!, + valleyRounding: ui.lerpDouble(0.0, valleyRounding, t)!, + ); + } else { + // Have a slightly different lerp for two-pointed stars, since they get + // kind of squirrelly with near-zero innerRadiusRatios. + final double lerpedPoints = ui.lerpDouble(points, 2, t)!; + return StarBorder( + side: BorderSide.lerp(a.side, side, t), + points: lerpedPoints, + rotation: rotation, + innerRadiusRatio: ui.lerpDouble(1, innerRadiusRatio, t)!, + pointRounding: ui.lerpDouble(0.5, pointRounding, t)!, + valleyRounding: ui.lerpDouble(0.5, valleyRounding, t)!, + ); + } + } + + if (a is StadiumBorder) { + // Lerp from a stadium to a circle first, and from there to a star. + final BorderSide lerpedSide = BorderSide.lerp(a.side, side, t); + return _twoPhaseLerp( + t, + 0.5, + (double t) => a.lerpTo(CircleBorder(side: lerpedSide), t), + (double t) => lerpFrom(CircleBorder(side: lerpedSide), t), + ); + } + if (a is RoundedRectangleBorder) { + // Lerp from a rectangle to a stadium, then from a Stadium to a circle, + // then from a circle to a star. + final BorderSide lerpedSide = BorderSide.lerp(a.side, side, t); + return _twoPhaseLerp( + t, + 1 / 3, + (double t) { + return StadiumBorder(side: lerpedSide).lerpFrom(a, t); + }, + (double t) { + return _twoPhaseLerp( + t, + 0.5, + (double t) => StadiumBorder(side: lerpedSide).lerpTo(CircleBorder(side: lerpedSide), t), + (double t) => lerpFrom(CircleBorder(side: lerpedSide), t), + ); + }, + ); + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) { + if (t == 0) { + return this; + } + if (t == 1.0) { + return b; + } + if (b is StarBorder) { + return StarBorder( + side: BorderSide.lerp(side, b.side, t), + points: ui.lerpDouble(points, b.points, t)!, + rotation: ui.lerpDouble(_rotationRadians, b._rotationRadians, t)! * _kRadToDeg, + innerRadiusRatio: ui.lerpDouble(innerRadiusRatio, b.innerRadiusRatio, t)!, + pointRounding: ui.lerpDouble(pointRounding, b.pointRounding, t)!, + valleyRounding: ui.lerpDouble(valleyRounding, b.valleyRounding, t)!, + squash: ui.lerpDouble(squash, b.squash, t)!, + ); + } + if (b is CircleBorder) { + // Have a slightly different lerp for two-pointed stars, since they get + // kind of squirrelly with near-zero innerRadiusRatios. + if (points >= 2.5) { + final double lerpedPoints = ui.lerpDouble(points, points.round(), t)!; + return StarBorder( + side: BorderSide.lerp(side, b.side, t), + points: lerpedPoints, + rotation: rotation, + innerRadiusRatio: ui.lerpDouble(innerRadiusRatio, math.cos(math.pi / lerpedPoints), t)!, + pointRounding: ui.lerpDouble(pointRounding, 1.0, t)!, + valleyRounding: ui.lerpDouble(valleyRounding, 0.0, t)!, + ); + } else { + final double lerpedPoints = ui.lerpDouble(points, 2, t)!; + return StarBorder( + side: BorderSide.lerp(side, b.side, t), + points: lerpedPoints, + rotation: rotation, + innerRadiusRatio: ui.lerpDouble(innerRadiusRatio, 1, t)!, + pointRounding: ui.lerpDouble(pointRounding, 0.5, t)!, + valleyRounding: ui.lerpDouble(valleyRounding, 0.5, t)!, + ); + } + } + if (b is StadiumBorder) { + // Lerp to a circle first, then to a stadium. + final BorderSide lerpedSide = BorderSide.lerp(side, b.side, t); + return _twoPhaseLerp( + t, + 0.5, + (double t) => lerpTo(CircleBorder(side: lerpedSide), t), + (double t) => b.lerpFrom(CircleBorder(side: lerpedSide), t), + ); + } + if (b is RoundedRectangleBorder) { + // Lerp to a circle, and then to a stadium, then to a rounded rect. + final BorderSide lerpedSide = BorderSide.lerp(side, b.side, t); + return _twoPhaseLerp( + t, + 2 / 3, + (double t) { + return _twoPhaseLerp( + t, + 0.5, + (double t) => lerpTo(CircleBorder(side: lerpedSide), t), + (double t) => StadiumBorder(side: lerpedSide).lerpFrom(CircleBorder(side: lerpedSide), t), + ); + }, + (double t) { + return StadiumBorder(side: lerpedSide).lerpTo(b, t); + }, + ); + } + return super.lerpTo(b, t); + } + + @override + StarBorder copyWith({ + BorderSide? side, + double? points, + double? innerRadiusRatio, + double? pointRounding, + double? valleyRounding, + double? rotation, + double? squash, + }) { + return StarBorder( + side: side ?? this.side, + points: points ?? this.points, + rotation: rotation ?? this.rotation, + innerRadiusRatio: innerRadiusRatio ?? this.innerRadiusRatio, + pointRounding: pointRounding ?? this.pointRounding, + valleyRounding: valleyRounding ?? this.valleyRounding, + squash: squash ?? this.squash, + ); + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + final Rect adjustedRect; + switch (side.strokeAlign) { + case StrokeAlign.inside: + adjustedRect = rect.deflate(side.width); + break; + case StrokeAlign.center: + adjustedRect = rect.deflate(side.width / 2); + break; + case StrokeAlign.outside: + adjustedRect = rect; + break; + } + return _StarGenerator( + points: points, + rotation: _rotationRadians, + innerRadiusRatio: innerRadiusRatio, + pointRounding: pointRounding, + valleyRounding: valleyRounding, + squash: squash, + ).generate(adjustedRect); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + return _StarGenerator( + points: points, + rotation: _rotationRadians, + innerRadiusRatio: innerRadiusRatio, + pointRounding: pointRounding, + valleyRounding: valleyRounding, + squash: squash, + ).generate(rect); + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + switch (side.style) { + case BorderStyle.none: + break; + case BorderStyle.solid: + final Rect adjustedRect; + switch (side.strokeAlign) { + case StrokeAlign.inside: + adjustedRect = rect.deflate(side.width / 2); + break; + case StrokeAlign.center: + adjustedRect = rect; + break; + case StrokeAlign.outside: + adjustedRect = rect.inflate(side.width / 2); + break; + } + final Path path = _StarGenerator( + points: points, + rotation: _rotationRadians, + innerRadiusRatio: innerRadiusRatio, + pointRounding: pointRounding, + valleyRounding: valleyRounding, + squash: squash, + ).generate(adjustedRect); + canvas.drawPath(path, side.toPaint()); + } + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is StarBorder && other.side == side; + } + + @override + int get hashCode => side.hashCode; + + @override + String toString() { + return '${objectRuntimeType(this, 'StarBorder')}($side, points: $points, innerRadiusRatio: $innerRadiusRatio)'; + } +} + +class _PointInfo { + _PointInfo({ + required this.valley, + required this.point, + required this.valleyArc1, + required this.pointArc1, + required this.valleyArc2, + required this.pointArc2, + }); + + Offset valley; + Offset point; + Offset valleyArc1; + Offset pointArc1; + Offset pointArc2; + Offset valleyArc2; +} + +class _StarGenerator { + const _StarGenerator({ + required this.points, + required this.innerRadiusRatio, + required this.pointRounding, + required this.valleyRounding, + required this.rotation, + required this.squash, + }) : assert(points > 1), + assert(innerRadiusRatio == null || innerRadiusRatio <= 1), + assert(innerRadiusRatio == null || innerRadiusRatio >= 0), + assert(squash >= 0), + assert(squash <= 1), + assert(pointRounding >= 0), + assert(pointRounding <= 1), + assert(valleyRounding >= 0), + assert(valleyRounding <= 1), + assert(pointRounding + valleyRounding <= 1); + + final double points; + final double innerRadiusRatio; + final double pointRounding; + final double valleyRounding; + final double rotation; + final double squash; + bool get isStar => innerRadiusRatio != null; + + Path generate(Rect rect) { + final double radius = rect.shortestSide / 2; + final Offset center = rect.center; + + // The minimum allowed inner radius ratio. Numerical instabilities occur near + // zero, so we just don't allow values in that range. + const double minInnerRadiusRatio = .002; + + // Map the innerRadiusRatio so that we don't get values close to zero, since + // things get a little squirrelly there because the path thinks that the + // length of the conicTo is small enough that it can render it as a straight + // line, even though it will be scaled up later. This maps the range from + // [0, 1] to [minInnerRadiusRatio, 1]. + final double mappedInnerRadiusRatio = (innerRadiusRatio * (1.0 - minInnerRadiusRatio)) + minInnerRadiusRatio; + + // First, generate the "points" of the star. + final List<_PointInfo> points = <_PointInfo>[]; + final double maxDiameter = 2.0 * + _generatePoints( + pointList: points, + center: center, + radius: radius, + innerRadius: radius * mappedInnerRadiusRatio, + ); + + // Calculate the endpoints of each of the arcs, then draw the arcs. + final Path path = Path(); + _drawPoints(path, points); + + Offset scale = Offset(rect.width / maxDiameter, rect.height / maxDiameter); + if (rect.shortestSide == rect.width) { + scale = Offset(scale.dx, squash * scale.dy + (1 - squash) * scale.dx); + } else { + scale = Offset(squash * scale.dx + (1 - squash) * scale.dy, scale.dy); + } + + // Scale the border so that it matches the size of the widget rectangle, so + // that "rotation" of the shape doesn't affect how much of the rectangle it + // covers. + final Matrix4 squashMatrix = Matrix4.translationValues(rect.center.dx, rect.center.dy, 0); + squashMatrix.multiply(Matrix4.diagonal3Values(scale.dx, scale.dy, 1)); + squashMatrix.multiply(Matrix4.rotationZ(rotation)); + squashMatrix.multiply(Matrix4.translationValues(-rect.center.dx, -rect.center.dy, 0)); + return path.transform(squashMatrix.storage); + } + + double _generatePoints({ + required List<_PointInfo> pointList, + required Offset center, + required double radius, + required double innerRadius, + }) { + final double step = math.pi / points; + // Start initial rotation one step before zero. + double angle = -math.pi / 2 - step; + Offset valley = Offset( + center.dx + math.cos(angle) * innerRadius, + center.dy + math.sin(angle) * innerRadius, + ); + + // In order to do overall scale properly, calculate the actual radius at the + // point, taking into account the rounding of the points and the weight of + // the corner point. This effectively is evaluating the rational quadratic + // bezier at the midpoint of the curve. + Offset getCurveMidpoint(Offset a, Offset b, Offset c, Offset a1, Offset c1) { + final double angle = _getAngle(a, b, c); + final double w = _getWeight(angle) / 2; + return (a1 / 4 + b * w + c1 / 4) / (0.5 + w); + } + + double addPoint( + double pointAngle, + double pointStep, + double pointRadius, + double pointInnerRadius, + ) { + pointAngle += pointStep; + final Offset point = Offset( + center.dx + math.cos(pointAngle) * pointRadius, + center.dy + math.sin(pointAngle) * pointRadius, + ); + pointAngle += pointStep; + final Offset nextValley = Offset( + center.dx + math.cos(pointAngle) * pointInnerRadius, + center.dy + math.sin(pointAngle) * pointInnerRadius, + ); + final Offset valleyArc1 = valley + (point - valley) * valleyRounding; + final Offset pointArc1 = point + (valley - point) * pointRounding; + final Offset pointArc2 = point + (nextValley - point) * pointRounding; + final Offset valleyArc2 = nextValley + (point - nextValley) * valleyRounding; + + pointList.add(_PointInfo( + valley: valley, + point: point, + valleyArc1: valleyArc1, + pointArc1: pointArc1, + pointArc2: pointArc2, + valleyArc2: valleyArc2, + )); + valley = nextValley; + return pointAngle; + } + + final double remainder = points - points.truncateToDouble(); + final bool hasIntegerSides = remainder < 1e-6; + final double wholeSides = points - (hasIntegerSides ? 0 : 1); + for (int i = 0; i < wholeSides; i += 1) { + angle = addPoint(angle, step, radius, innerRadius); + } + + double valleyRadius = 0; + double pointRadius = 0; + final _PointInfo thisPoint = pointList[0]; + final _PointInfo nextPoint = pointList[1]; + + final Offset pointMidpoint = + getCurveMidpoint(thisPoint.valley, thisPoint.point, nextPoint.valley, thisPoint.pointArc1, thisPoint.pointArc2); + final Offset valleyMidpoint = getCurveMidpoint( + thisPoint.point, nextPoint.valley, nextPoint.point, thisPoint.valleyArc2, nextPoint.valleyArc1); + valleyRadius = (valleyMidpoint - center).distance; + pointRadius = (pointMidpoint - center).distance; + + // Add the final point to close the shape if there are fractional sides to + // account for. + if (!hasIntegerSides) { + final double effectiveInnerRadius = math.max(valleyRadius, innerRadius); + final double endingRadius = effectiveInnerRadius + remainder * (radius - effectiveInnerRadius); + addPoint(angle, step * remainder, endingRadius, innerRadius); + } + + // The rounding added to the valley radius can sometimes push it outside of + // the rounding of the point, since the rounding amount can be different, so + // we have to evaluate both the valley and the point radii, and pick the + // largest. + return math.max(valleyRadius, pointRadius); + } + + void _drawPoints(Path path, List<_PointInfo> points) { + final Offset startingPoint = points.first.pointArc1; + path.moveTo(startingPoint.dx, startingPoint.dy); + final double pointAngle = _getAngle(points[0].valley, points[0].point, points[1].valley); + final double pointWeight = _getWeight(pointAngle); + final double valleyAngle = _getAngle(points[1].point, points[1].valley, points[0].point); + final double valleyWeight = _getWeight(valleyAngle); + + for (int i = 0; i < points.length; i += 1) { + final _PointInfo point = points[i]; + final _PointInfo nextPoint = points[(i + 1) % points.length]; + path.lineTo(point.pointArc1.dx, point.pointArc1.dy); + if (pointAngle != 180 && pointAngle != 0) { + path.conicTo(point.point.dx, point.point.dy, point.pointArc2.dx, point.pointArc2.dy, pointWeight); + } else { + path.lineTo(point.pointArc2.dx, point.pointArc2.dy); + } + path.lineTo(point.valleyArc2.dx, point.valleyArc2.dy); + if (valleyAngle != 180 && valleyAngle != 0) { + path.conicTo( + nextPoint.valley.dx, nextPoint.valley.dy, nextPoint.valleyArc1.dx, nextPoint.valleyArc1.dy, valleyWeight); + } else { + path.lineTo(nextPoint.valleyArc1.dx, nextPoint.valleyArc1.dy); + } + } + path.close(); + } + + double _getWeight(double angle) { + return math.cos((angle / 2) % (math.pi / 2)); + } + + // Returns the included angle between points ABC in radians. + double _getAngle(Offset a, Offset b, Offset c) { + if (a == c || b == c || b == a) { + return 0; + } + final Offset u = a - b; + final Offset v = c - b; + final double dot = u.dx * v.dx + u.dy * v.dy; + final double m1 = b.dx == a.dx ? double.infinity : -u.dy / -u.dx; + final double m2 = b.dx == c.dx ? double.infinity : -v.dy / -v.dx; + double angle = math.atan2(m1 - m2, 1 + m1 * m2).abs(); + if (dot < 0) { + angle += math.pi; + } + return angle; + } +}
diff --git a/packages/flutter/test/painting/star_border_test.dart b/packages/flutter/test/painting/star_border_test.dart new file mode 100644 index 0000000..6694c9c --- /dev/null +++ b/packages/flutter/test/painting/star_border_test.dart
@@ -0,0 +1,198 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@Tags(<String>['reduced-test-set']) + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future<void> testBorder(WidgetTester tester, String name, StarBorder border, + {ShapeBorder? lerpTo, ShapeBorder? lerpFrom, double lerpAmount = 0}) async { + assert(lerpTo == null || lerpFrom == null); // They can't both be set. + ShapeBorder shape; + if (lerpTo != null) { + shape = border.lerpTo(lerpTo, lerpAmount)!; + } else if (lerpFrom != null) { + shape = border.lerpFrom(lerpFrom, lerpAmount)!; + } else { + shape = border; + } + await tester.pumpWidget( + Container( + alignment: Alignment.center, + width: 200, + height: 100, + decoration: ShapeDecoration( + color: const Color(0xff000000), + shape: shape, + ), + ), + ); + await expectLater( + find.byType(Container), + matchesGoldenFile('painting.star_border.$name.png'), + ); + } + + test('StarBorder defaults', () { + const StarBorder star = StarBorder(); + expect(star.side, BorderSide.none); + expect(star.points, 5); + expect(star.innerRadiusRatio, 0.4); + expect(star.rotation, 0); + expect(star.pointRounding, 0); + expect(star.valleyRounding, 0); + expect(star.squash, 0); + + const StarBorder polygon = StarBorder.polygon(); + expect(polygon.points, 5); + expect(polygon.pointRounding, 0); + expect(polygon.rotation, 0); + expect(polygon.squash, 0); + }); + + test('StarBorder copyWith, ==, hashCode', () { + const BorderSide side = BorderSide(width: 10.0, color: Color(0xff123456)); + final StarBorder copy = const StarBorder().copyWith( + side: side, + points: 3, + innerRadiusRatio: 0.1, + pointRounding: 0.2, + valleyRounding: 0.3, + rotation: 180, + squash: 0.4, + ); + const StarBorder expected = StarBorder( + side: side, + points: 3, + innerRadiusRatio: 0.1, + pointRounding: 0.2, + valleyRounding: 0.3, + rotation: 180, + squash: 0.4, + ); + expect(const StarBorder(), equals(const StarBorder().copyWith())); + expect(copy, equals(expected)); + expect(copy.hashCode, equals(expected.hashCode)); + }); + + testWidgets('StarBorder basic geometry', (WidgetTester tester) async { + await testBorder(tester, 'basic_star', const StarBorder()); + await testBorder(tester, 'basic_polygon', const StarBorder.polygon()); + }); + + testWidgets('StarBorder parameters', (WidgetTester tester) async { + await testBorder(tester, 'points_6', const StarBorder(points: 6)); + await testBorder(tester, 'points_2', const StarBorder(points: 2)); + await testBorder(tester, 'inner_radius_0', const StarBorder(innerRadiusRatio: 0.0)); + await testBorder(tester, 'inner_radius_2', const StarBorder(innerRadiusRatio: 0.2)); + await testBorder(tester, 'inner_radius_7', const StarBorder(innerRadiusRatio: 0.7)); + await testBorder(tester, 'point_rounding_2', const StarBorder(pointRounding: 0.2)); + await testBorder(tester, 'point_rounding_7', const StarBorder(pointRounding: 0.7)); + await testBorder(tester, 'point_rounding_10', const StarBorder(pointRounding: 1.0)); + await testBorder(tester, 'valley_rounding_2', const StarBorder(valleyRounding: 0.2)); + await testBorder(tester, 'valley_rounding_7', const StarBorder(valleyRounding: 0.7)); + await testBorder(tester, 'valley_rounding_10', const StarBorder(valleyRounding: 1.0)); + await testBorder(tester, 'squash_2', const StarBorder(squash: 0.2)); + await testBorder(tester, 'squash_7', const StarBorder(squash: 0.7)); + await testBorder(tester, 'squash_10', const StarBorder(squash: 1.0)); + await testBorder(tester, 'rotate_27', const StarBorder(rotation: 27)); + await testBorder(tester, 'rotate_270', const StarBorder(rotation: 270)); + await testBorder(tester, 'rotate_360', const StarBorder(rotation: 360)); + await testBorder(tester, 'side_none', const StarBorder(side: BorderSide(style: BorderStyle.none))); + await testBorder(tester, 'side_1', const StarBorder(side: BorderSide(color: Color(0xffff0000)))); + await testBorder(tester, 'side_10', const StarBorder(side: BorderSide(color: Color(0xffff0000), width: 10))); + await testBorder(tester, 'side_align_center', + const StarBorder(side: BorderSide(color: Color(0xffff0000), strokeAlign: StrokeAlign.center))); + await testBorder(tester, 'side_align_outside', + const StarBorder(side: BorderSide(color: Color(0xffff0000), strokeAlign: StrokeAlign.outside))); + }); + + testWidgets('StarBorder.polygon parameters', (WidgetTester tester) async { + await testBorder(tester, 'poly_sides_6', const StarBorder.polygon(sides: 6)); + await testBorder(tester, 'poly_sides_2', const StarBorder.polygon(sides: 2)); + await testBorder(tester, 'poly_point_rounding_2', const StarBorder.polygon(pointRounding: 0.2)); + await testBorder(tester, 'poly_point_rounding_7', const StarBorder.polygon(pointRounding: 0.7)); + await testBorder(tester, 'poly_point_rounding_10', const StarBorder.polygon(pointRounding: 1.0)); + await testBorder(tester, 'poly_squash_2', const StarBorder.polygon(squash: 0.2)); + await testBorder(tester, 'poly_squash_7', const StarBorder.polygon(squash: 0.7)); + await testBorder(tester, 'poly_squash_10', const StarBorder.polygon(squash: 1.0)); + await testBorder(tester, 'poly_rotate_27', const StarBorder.polygon(rotation: 27)); + await testBorder(tester, 'poly_rotate_270', const StarBorder.polygon(rotation: 270)); + await testBorder(tester, 'poly_rotate_360', const StarBorder.polygon(rotation: 360)); + await testBorder(tester, 'poly_side_none', const StarBorder.polygon(side: BorderSide(style: BorderStyle.none))); + await testBorder(tester, 'poly_side_1', const StarBorder.polygon(side: BorderSide(color: Color(0xffff0000)))); + await testBorder( + tester, 'poly_side_10', const StarBorder.polygon(side: BorderSide(color: Color(0xffff0000), width: 10))); + await testBorder(tester, 'poly_side_align_center', + const StarBorder.polygon(side: BorderSide(color: Color(0xffff0000), strokeAlign: StrokeAlign.center))); + await testBorder(tester, 'poly_side_align_outside', + const StarBorder.polygon(side: BorderSide(color: Color(0xffff0000), strokeAlign: StrokeAlign.outside))); + }); + + testWidgets('StarBorder lerped with StarBorder', (WidgetTester tester) async { + const StarBorder from = StarBorder(); + const ShapeBorder otherBorder = StarBorder( + points: 6, + pointRounding: 0.5, + valleyRounding: 0.5, + innerRadiusRatio: 0.5, + rotation: 90, + ); + await testBorder(tester, 'to_star_border_2', from, lerpTo: otherBorder, lerpAmount: 0.2); + await testBorder(tester, 'to_star_border_7', from, lerpTo: otherBorder, lerpAmount: 0.7); + await testBorder(tester, 'to_star_border_10', from, lerpTo: otherBorder, lerpAmount: 1.0); + await testBorder(tester, 'from_star_border_2', from, lerpFrom: otherBorder, lerpAmount: 0.2); + await testBorder(tester, 'from_star_border_7', from, lerpFrom: otherBorder, lerpAmount: 0.7); + await testBorder(tester, 'from_star_border_10', from, lerpFrom: otherBorder, lerpAmount: 1.0); + }); + + testWidgets('StarBorder lerped with CircleBorder', (WidgetTester tester) async { + const StarBorder from = StarBorder(); + const ShapeBorder otherBorder = CircleBorder(); + await testBorder(tester, 'to_circle_border_2', from, lerpTo: otherBorder, lerpAmount: 0.2); + await testBorder(tester, 'to_circle_border_7', from, lerpTo: otherBorder, lerpAmount: 0.7); + await testBorder(tester, 'to_circle_border_10', from, lerpTo: otherBorder, lerpAmount: 1.0); + await testBorder(tester, 'from_circle_border_2', from, lerpFrom: otherBorder, lerpAmount: 0.2); + await testBorder(tester, 'from_circle_border_7', from, lerpFrom: otherBorder, lerpAmount: 0.7); + await testBorder(tester, 'from_circle_border_10', from, lerpFrom: otherBorder, lerpAmount: 1.0); + }); + + testWidgets('StarBorder lerped with RoundedRectangleBorder', (WidgetTester tester) async { + const StarBorder from = StarBorder(); + const RoundedRectangleBorder rectangleBorder = RoundedRectangleBorder(); + await testBorder(tester, 'to_rect_border_2', from, lerpTo: rectangleBorder, lerpAmount: 0.2); + await testBorder(tester, 'to_rect_border_7', from, lerpTo: rectangleBorder, lerpAmount: 0.7); + await testBorder(tester, 'to_rect_border_10', from, lerpTo: rectangleBorder, lerpAmount: 1.0); + await testBorder(tester, 'from_rect_border_2', from, lerpFrom: rectangleBorder, lerpAmount: 0.2); + await testBorder(tester, 'from_rect_border_7', from, lerpFrom: rectangleBorder, lerpAmount: 0.7); + await testBorder(tester, 'from_rect_border_10', from, lerpFrom: rectangleBorder, lerpAmount: 1.0); + + const RoundedRectangleBorder roundedRectBorder = RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(10.0), + bottomRight: Radius.circular(10.0), + ), + ); + await testBorder(tester, 'to_rrect_border_2', from, lerpTo: roundedRectBorder, lerpAmount: 0.2); + await testBorder(tester, 'to_rrect_border_7', from, lerpTo: roundedRectBorder, lerpAmount: 0.7); + await testBorder(tester, 'to_rrect_border_10', from, lerpTo: roundedRectBorder, lerpAmount: 1.0); + await testBorder(tester, 'from_rrect_border_2', from, lerpFrom: roundedRectBorder, lerpAmount: 0.2); + await testBorder(tester, 'from_rrect_border_7', from, lerpFrom: roundedRectBorder, lerpAmount: 0.7); + await testBorder(tester, 'from_rrect_border_10', from, lerpFrom: roundedRectBorder, lerpAmount: 1.0); + }); + + testWidgets('StarBorder lerped with StadiumBorder', (WidgetTester tester) async { + const StarBorder from = StarBorder(); + const StadiumBorder stadiumBorder = StadiumBorder(); + + await testBorder(tester, 'to_stadium_border_2', from, lerpTo: stadiumBorder, lerpAmount: 0.2); + await testBorder(tester, 'to_stadium_border_7', from, lerpTo: stadiumBorder, lerpAmount: 0.7); + await testBorder(tester, 'to_stadium_border_10', from, lerpTo: stadiumBorder, lerpAmount: 1.0); + await testBorder(tester, 'from_stadium_border_2', from, lerpFrom: stadiumBorder, lerpAmount: 0.2); + await testBorder(tester, 'from_stadium_border_7', from, lerpFrom: stadiumBorder, lerpAmount: 0.7); + await testBorder(tester, 'from_stadium_border_10', from, lerpFrom: stadiumBorder, lerpAmount: 1.0); + }); +}