Commit 03e094aa authored by Hixie's avatar Hixie

Convert Persistent Bottom Sheets to a Scaffold API

- `Scaffold.of(context).showBottomSheet(widget);`
- Returns an object with .closed Future and .close() method.
- Uses a StateRoute to handle back button.
- Take the Navigator logic out of the BottomSheet widget.
- Support showing a sheet while an old one is going away.
- Add Navigator.remove().
parent 6a2bd421
......@@ -20,7 +20,6 @@ class StockHome extends StatefulComponent {
class StockHomeState extends State<StockHome> {
final GlobalKey scaffoldKey = new GlobalKey();
final GlobalKey<PlaceholderState> _bottomSheetPlaceholderKey = new GlobalKey<PlaceholderState>();
bool _isSearching = false;
String _searchQuery;
......@@ -202,11 +201,7 @@ class StockHomeState extends State<StockHome> {
Navigator.of(context).pushNamed('/stock/${stock.symbol}', mostValuableKeys: mostValuableKeys);
},
onShow: (Stock stock, Key arrowKey) {
showBottomSheet(
placeholderKey: _bottomSheetPlaceholderKey,
context: context,
child: new StockSymbolBottomSheet(stock: stock)
);
scaffoldKey.currentState.showBottomSheet((BuildContext context) => new StockSymbolBottomSheet(stock: stock));
}
);
}
......@@ -256,12 +251,14 @@ class StockHomeState extends State<StockHome> {
showModalBottomSheet(
// TODO(ianh): Fill this out.
context: context,
child: new Column([
new Input(
key: companyNameKey,
placeholder: 'Company Name'
),
])
builder: (BuildContext context) {
return new Column([
new Input(
key: companyNameKey,
placeholder: 'Company Name'
),
]);
}
);
}
......@@ -278,7 +275,6 @@ class StockHomeState extends State<StockHome> {
key: scaffoldKey,
toolBar: _isSearching ? buildSearchBar() : buildToolBar(),
body: buildTabNavigator(),
bottomSheet: new Placeholder(key: _bottomSheetPlaceholderKey),
floatingActionButton: buildFloatingActionButton()
);
}
......
......@@ -80,11 +80,11 @@ class StockSymbolBottomSheet extends StatelessComponent {
Widget build(BuildContext context) {
return new Container(
child: new StockSymbolView(stock: stock),
padding: new EdgeDims.all(10.0),
decoration: new BoxDecoration(
border: new Border(top: new BorderSide(color: Colors.black26, width: 1.0))
)
),
child: new StockSymbolView(stock: stock)
);
}
}
......@@ -18,17 +18,31 @@ const double _kCloseProgressThreshold = 0.5;
const Color _kTransparent = const Color(0x00000000);
const Color _kBarrierColor = Colors.black54;
class _BottomSheetDragController extends StatelessComponent {
_BottomSheetDragController({
class BottomSheet extends StatelessComponent {
BottomSheet({
Key key,
this.performance,
this.child,
this.childHeight
}) : super(key: key);
this.onClosing,
this.childHeight,
this.builder
}) : super(key: key) {
assert(onClosing != null);
}
/// The performance that controls the bottom sheet's position. The BottomSheet
/// widget will manipulate the position of this performance, it is not just a
/// passive observer.
final Performance performance;
final Widget child;
final VoidCallback onClosing;
final double childHeight;
final WidgetBuilder builder;
static Performance createPerformance() {
return new Performance(
duration: _kBottomSheetDuration,
debugLabel: 'BottomSheet'
);
}
bool get _dismissUnderway => performance.direction == AnimationDirection.reverse;
......@@ -42,13 +56,11 @@ class _BottomSheetDragController extends StatelessComponent {
if (_dismissUnderway)
return;
if (velocity.dy > _kMinFlingVelocity) {
performance.fling(velocity: -velocity.dy / childHeight).then((_) {
Navigator.of(context).pop();
});
performance.fling(velocity: -velocity.dy / childHeight);
onClosing();
} else if (performance.progress < _kCloseProgressThreshold) {
performance.fling(velocity: -1.0).then((_) {
Navigator.of(context).pop();
});
performance.fling(velocity: -1.0);
onClosing();
} else {
performance.forward();
}
......@@ -58,46 +70,19 @@ class _BottomSheetDragController extends StatelessComponent {
return new GestureDetector(
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: (Offset velocity) { _handleDragEnd(velocity, context); },
child: child
child: new Material(
child: builder(context)
)
);
}
}
class _BottomSheetRoute extends OverlayRoute {
_BottomSheetRoute({ this.completer, this.child });
final Completer completer;
final Widget child;
Performance performance;
void didPush(OverlayState overlay, OverlayEntry insertionPoint) {
performance = new Performance(duration: _kBottomSheetDuration, debugLabel: debugLabel)
..forward();
super.didPush(overlay, insertionPoint);
}
void didPop(dynamic result) {
void finish() {
super.didPop(result); // clear the overlay entries
completer.complete(result);
}
if (performance.isDismissed)
finish();
else
performance.reverse().then((_) { finish(); });
}
String get debugLabel => '$runtimeType';
String toString() => '$runtimeType(performance: $performance)';
}
// PERSISTENT BOTTOM SHEETS
class _ModalBottomSheet extends StatefulComponent {
_ModalBottomSheet({ Key key, this.route }) : super(key: key);
// See scaffold.dart
final _ModalBottomSheetRoute route;
_ModalBottomSheetState createState() => new _ModalBottomSheetState();
}
// MODAL BOTTOM SHEETS
class _ModalBottomSheetLayout extends OneChildLayoutDelegate {
// The distance from the bottom of the parent to the top of the BottomSheet child.
......@@ -118,6 +103,14 @@ class _ModalBottomSheetLayout extends OneChildLayoutDelegate {
}
}
class _ModalBottomSheet extends StatefulComponent {
_ModalBottomSheet({ Key key, this.route }) : super(key: key);
final _ModalBottomSheetRoute route;
_ModalBottomSheetState createState() => new _ModalBottomSheetState();
}
class _ModalBottomSheetState extends State<_ModalBottomSheet> {
final _ModalBottomSheetLayout _layout = new _ModalBottomSheetLayout();
......@@ -133,10 +126,11 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> {
child: new CustomOneChildLayout(
delegate: _layout,
token: _layout.childTop.value,
child: new _BottomSheetDragController(
child: new BottomSheet(
performance: config.route.performance,
child: new Material(child: config.route.child),
childHeight: _layout.childTop.end
onClosing: () { Navigator.of(context).pop(); },
childHeight: _layout.childTop.end,
builder: config.route.builder
)
)
);
......@@ -146,9 +140,30 @@ class _ModalBottomSheetState extends State<_ModalBottomSheet> {
}
}
class _ModalBottomSheetRoute extends _BottomSheetRoute {
_ModalBottomSheetRoute({ Completer completer, Widget child })
: super(completer: completer, child: child);
class _ModalBottomSheetRoute extends OverlayRoute {
_ModalBottomSheetRoute({ this.completer, this.builder });
final Completer completer;
final WidgetBuilder builder;
Performance performance;
void didPush(OverlayState overlay, OverlayEntry insertionPoint) {
performance = BottomSheet.createPerformance()
..forward();
super.didPush(overlay, insertionPoint);
}
void _finish(dynamic result) {
super.didPop(result); // clear the overlay entries
completer.complete(result);
}
void didPop(dynamic result) {
if (performance.isDismissed)
_finish(result);
else
performance.reverse().then((_) { _finish(result); });
}
Widget _buildModalBarrier(BuildContext context) {
return new AnimatedModalBarrier(
......@@ -168,61 +183,18 @@ class _ModalBottomSheetRoute extends _BottomSheetRoute {
_buildModalBarrier,
_buildBottomSheet,
];
String get debugLabel => '$runtimeType';
String toString() => '$runtimeType(performance: $performance)';
}
Future showModalBottomSheet({ BuildContext context, Widget child }) {
assert(child != null);
Future showModalBottomSheet({ BuildContext context, WidgetBuilder builder }) {
assert(context != null);
assert(builder != null);
final Completer completer = new Completer();
Navigator.of(context).pushEphemeral(new _ModalBottomSheetRoute(
completer: completer,
child: child
builder: builder
));
return completer.future;
}
class _PersistentBottomSheet extends StatefulComponent {
_PersistentBottomSheet({ Key key, this.route }) : super(key: key);
final _BottomSheetRoute route;
_PersistentBottomSheetState createState() => new _PersistentBottomSheetState();
}
class _PersistentBottomSheetState extends State<_PersistentBottomSheet> {
double _childHeight;
void _updateChildHeight(Size newSize) {
setState(() {
_childHeight = newSize.height;
});
}
Widget build(BuildContext context) {
return new AlignTransition(
performance: config.route.performance,
alignment: new AnimatedValue<FractionalOffset>(const FractionalOffset(0.0, 0.0)),
heightFactor: new AnimatedValue<double>(0.0, end: 1.0),
child: new _BottomSheetDragController(
performance: config.route.performance,
childHeight: _childHeight,
child: new Material(
child: new SizeObserver(child: config.route.child, onSizeChanged: _updateChildHeight)
)
)
);
}
}
Future showBottomSheet({ BuildContext context, GlobalKey<PlaceholderState> placeholderKey, Widget child }) {
assert(child != null);
assert(placeholderKey != null);
final Completer completer = new Completer();
_BottomSheetRoute route = new _BottomSheetRoute(child: child, completer: completer);
placeholderKey.currentState.child = new _PersistentBottomSheet(route: route);
Navigator.of(context).pushEphemeral(route);
return completer.future.then((_) {
// If our overlay has been obscured by an opaque OverlayEntry then currentState
// will have been cleared already.
placeholderKey.currentState?.child = null;
});
}
......@@ -11,9 +11,10 @@ import 'package:flutter/animation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'bottom_sheet.dart';
import 'material.dart';
import 'tool_bar.dart';
import 'snack_bar.dart';
import 'tool_bar.dart';
const double _kFloatingActionButtonMargin = 16.0; // TODO(hmuller): should be device dependent
......@@ -57,7 +58,7 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
if (isChild(_Child.bottomSheet)) {
bottomSheetSize = layoutChild(_Child.bottomSheet, fullWidthConstraints);
positionChild(_Child.bottomSheet, new Point(0.0, size.height - bottomSheetSize.height));
positionChild(_Child.bottomSheet, new Point((size.width - bottomSheetSize.width) / 2.0, size.height - bottomSheetSize.height));
}
if (isChild(_Child.snackBar)) {
......@@ -85,13 +86,11 @@ class Scaffold extends StatefulComponent {
Key key,
this.toolBar,
this.body,
this.bottomSheet,
this.floatingActionButton
}) : super(key: key);
final ToolBar toolBar;
final Widget body;
final Widget bottomSheet; // this is for non-modal bottom sheets
final Widget floatingActionButton;
static ScaffoldState of(BuildContext context) => context.ancestorStateOfType(ScaffoldState);
......@@ -101,6 +100,8 @@ class Scaffold extends StatefulComponent {
class ScaffoldState extends State<Scaffold> {
// SNACKBAR API
Queue<SnackBar> _snackBars = new Queue<SnackBar>();
Performance _snackBarPerformance;
Timer _snackBarTimer;
......@@ -108,6 +109,10 @@ class ScaffoldState extends State<Scaffold> {
void showSnackBar(SnackBar snackbar) {
_snackBarPerformance ??= SnackBar.createPerformance()
..addStatusListener(_handleSnackBarStatusChange);
if (_snackBars.isEmpty) {
assert(_snackBarPerformance.isDismissed);
_snackBarPerformance.forward();
}
setState(() {
_snackBars.addLast(snackbar.withPerformance(_snackBarPerformance));
});
......@@ -120,6 +125,8 @@ class ScaffoldState extends State<Scaffold> {
setState(() {
_snackBars.removeFirst();
});
if (_snackBars.isNotEmpty)
_snackBarPerformance.forward();
break;
case PerformanceStatus.completed:
setState(() {
......@@ -138,6 +145,63 @@ class ScaffoldState extends State<Scaffold> {
_snackBarTimer = null;
}
// PERSISTENT BOTTOM SHEET API
List<Widget> _dismissedBottomSheets;
BottomSheetController _currentBottomSheet;
BottomSheetController showBottomSheet(WidgetBuilder builder) {
if (_currentBottomSheet != null) {
_currentBottomSheet.close();
assert(_currentBottomSheet == null);
}
Completer completer = new Completer();
GlobalKey<_PersistentBottomSheetState> bottomSheetKey = new GlobalKey<_PersistentBottomSheetState>();
Performance performance = BottomSheet.createPerformance()
..forward();
_PersistentBottomSheet bottomSheet;
Route route = new StateRoute(
onPop: () {
assert(_currentBottomSheet._widget == bottomSheet);
assert(bottomSheetKey.currentState != null);
bottomSheetKey.currentState.close();
_dismissedBottomSheets ??= <Widget>[];
_dismissedBottomSheets.add(bottomSheet);
_currentBottomSheet = null;
completer.complete();
}
);
bottomSheet = new _PersistentBottomSheet(
key: bottomSheetKey,
performance: performance,
onClosing: () {
assert(_currentBottomSheet._widget == bottomSheet);
Navigator.of(context).remove(route);
},
onDismissed: () {
assert(_dismissedBottomSheets != null);
setState(() {
_dismissedBottomSheets.remove(bottomSheet);
});
},
builder: builder
);
Navigator.of(context).push(route);
setState(() {
_currentBottomSheet = new BottomSheetController._(
bottomSheet,
completer.future,
() => Navigator.of(context).remove(route),
setState
);
});
return _currentBottomSheet;
}
// INTERNALS
void dispose() {
_snackBarPerformance?.stop();
_snackBarPerformance = null;
......@@ -156,8 +220,6 @@ class ScaffoldState extends State<Scaffold> {
final Widget materialBody = config.body != null ? new Material(child: config.body) : null;
if (_snackBars.length > 0) {
if (_snackBarPerformance.isDismissed)
_snackBarPerformance.forward();
ModalRoute route = ModalRoute.of(context);
if (route == null || route.isCurrent) {
if (_snackBarPerformance.isCompleted && _snackBarTimer == null)
......@@ -171,12 +233,105 @@ class ScaffoldState extends State<Scaffold> {
final List<LayoutId>children = new List<LayoutId>();
_addIfNonNull(children, materialBody, _Child.body);
_addIfNonNull(children, paddedToolBar, _Child.toolBar);
_addIfNonNull(children, config.bottomSheet, _Child.bottomSheet);
if (_currentBottomSheet != null ||
(_dismissedBottomSheets != null && _dismissedBottomSheets.isNotEmpty)) {
List<Widget> bottomSheets = <Widget>[];
if (_dismissedBottomSheets != null && _dismissedBottomSheets.isNotEmpty)
bottomSheets.addAll(_dismissedBottomSheets);
if (_currentBottomSheet != null)
bottomSheets.add(_currentBottomSheet._widget);
Widget stack = new Stack(
bottomSheets,
alignment: const FractionalOffset(0.5, 1.0) // bottom-aligned, centered
);
_addIfNonNull(children, stack, _Child.bottomSheet);
}
if (_snackBars.isNotEmpty)
_addIfNonNull(children, _snackBars.first, _Child.snackBar);
_addIfNonNull(children, config.floatingActionButton, _Child.floatingActionButton);
return new CustomMultiChildLayout(children, delegate: _scaffoldLayout);
}
}
class BottomSheetController {
const BottomSheetController._(this._widget, this.closed, this.close, this.setState);
final Widget _widget;
final Future closed;
final VoidCallback close; // call this to close the bottom sheet
final StateSetter setState;
}
class _PersistentBottomSheet extends StatefulComponent {
_PersistentBottomSheet({
Key key,
this.performance,
this.onClosing,
this.onDismissed,
this.builder
}) : super(key: key);
final Performance performance;
final VoidCallback onClosing;
final VoidCallback onDismissed;
final WidgetBuilder builder;
_PersistentBottomSheetState createState() => new _PersistentBottomSheetState();
}
class _PersistentBottomSheetState extends State<_PersistentBottomSheet> {
// We take ownership of the performance given in the first configuration.
// We also share control of that performance with out BottomSheet widget.
void initState() {
super.initState();
assert(config.performance.status == PerformanceStatus.forward);
config.performance.addStatusListener(_handleStatusChange);
}
void didUpdateConfig(_PersistentBottomSheet oldConfig) {
super.didUpdateConfig(oldConfig);
assert(config.performance == oldConfig.performance);
}
void dispose() {
config.performance.stop();
super.dispose();
}
void close() {
config.performance.reverse();
}
void _handleStatusChange(PerformanceStatus status) {
if (status == PerformanceStatus.dismissed && config.onDismissed != null)
config.onDismissed();
}
double _childHeight;
void _updateChildHeight(Size newSize) {
setState(() {
_childHeight = newSize.height;
});
}
Widget build(BuildContext context) {
return new AlignTransition(
performance: config.performance,
alignment: new AnimatedValue<FractionalOffset>(const FractionalOffset(0.0, 0.0)),
heightFactor: new AnimatedValue<double>(0.0, end: 1.0),
child: new BottomSheet(
performance: config.performance,
onClosing: config.onClosing,
childHeight: _childHeight,
builder: (BuildContext context) => new SizeObserver(child: config.builder(context), onSizeChanged: _updateChildHeight)
)
);
}
}
......@@ -21,10 +21,10 @@ const Color _kSnackBackground = const Color(0xFF323232);
// TODO(ianh): Implement the Tablet version of snackbar if we're "on a tablet".
const Duration kSnackBarTransitionDuration = const Duration(milliseconds: 250);
const Duration _kSnackBarTransitionDuration = const Duration(milliseconds: 250);
const Duration kSnackBarShortDisplayDuration = const Duration(milliseconds: 1500);
const Duration kSnackBarMediumDisplayDuration = const Duration(milliseconds: 2750);
const Curve snackBarFadeCurve = const Interval(0.72, 1.0, curve: Curves.fastOutSlowIn);
const Curve _snackBarFadeCurve = const Interval(0.72, 1.0, curve: Curves.fastOutSlowIn);
class SnackBarAction extends StatelessComponent {
SnackBarAction({Key key, this.label, this.onPressed }) : super(key: key) {
......@@ -91,7 +91,7 @@ class SnackBar extends StatelessComponent {
style: new TextStyle(color: Theme.of(context).accentColor),
child: new FadeTransition(
performance: performance,
opacity: new AnimatedValue<double>(0.0, end: 1.0, curve: snackBarFadeCurve),
opacity: new AnimatedValue<double>(0.0, end: 1.0, curve: _snackBarFadeCurve),
child: new Row(children)
)
)
......@@ -105,7 +105,7 @@ class SnackBar extends StatelessComponent {
static Performance createPerformance() {
return new Performance(
duration: kSnackBarTransitionDuration,
duration: _kSnackBarTransitionDuration,
debugLabel: 'SnackBar'
);
}
......
......@@ -332,6 +332,9 @@ enum _StateLifecycle {
defunct,
}
/// The signature of setState() methods.
typedef void StateSetter(VoidCallback fn);
/// The logic and internal state for a StatefulComponent.
abstract class State<T extends StatefulComponent> {
/// The current configuration (an instance of the corresponding
......@@ -377,7 +380,7 @@ abstract class State<T extends StatefulComponent> {
/// If you just change the state directly without calling setState(), then the
/// component will not be scheduled for rebuilding, meaning that its rendering
/// will not be updated.
void setState(void fn()) {
void setState(VoidCallback fn) {
assert(_debugLifecycleState != _StateLifecycle.defunct);
fn();
_element.markNeedsBuild();
......
......@@ -133,6 +133,28 @@ class NavigatorState extends State<Navigator> {
assert(_ephemeral.isEmpty);
}
/// Pops the given route, if it's the current route. If it's not the current
/// route, removes it from the list of active routes without notifying any
/// observers or adjacent routes.
///
/// Do not use this for ModalRoutes, or indeed anything other than
/// StateRoutes. Doing so would cause very odd results, e.g. ModalRoutes would
/// get confused about who is current.
void remove(Route route, [dynamic result]) {
assert(_modal.contains(route));
assert(route.overlayEntries.isEmpty);
if (_modal.last == route) {
pop(result);
} else {
setState(() {
_modal.remove(route);
route.didPop(result);
});
}
}
/// Removes the current route, notifying the observer (if any), and the
/// previous routes (using [Route.didPopNext]).
void pop([dynamic result]) {
setState(() {
// We use setState to guarantee that we'll rebuild, since the routes can't
......
......@@ -26,7 +26,10 @@ void main() {
tester.pump();
expect(tester.findText('BottomSheet'), isNull);
showModalBottomSheet(context: context, child: new Text('BottomSheet')).then((_) {
showModalBottomSheet(
context: context,
builder: (BuildContext context) => new Text('BottomSheet')
).then((_) {
showBottomSheetThenCalled = true;
});
......@@ -42,7 +45,7 @@ void main() {
expect(showBottomSheetThenCalled, isTrue);
expect(tester.findText('BottomSheet'), isNull);
showModalBottomSheet(context: context, child: new Text('BottomSheet'));
showModalBottomSheet(context: context, builder: (BuildContext context) => new Text('BottomSheet'));
tester.pump(); // bottom sheet show animation starts
tester.pump(new Duration(seconds: 1)); // animation done
expect(tester.findText('BottomSheet'), isNotNull);
......@@ -58,45 +61,60 @@ void main() {
test('Verify that a downwards fling dismisses a persistent BottomSheet', () {
testWidgets((WidgetTester tester) {
GlobalKey<PlaceholderState> _bottomSheetPlaceholderKey = new GlobalKey<PlaceholderState>();
BuildContext context;
GlobalKey<ScaffoldState> scaffoldKey = new GlobalKey<ScaffoldState>();
bool showBottomSheetThenCalled = false;
tester.pumpWidget(new MaterialApp(
routes: <String, RouteBuilder>{
'/': (RouteArguments args) {
context = args.context;
return new Scaffold(
bottomSheet: new Placeholder(key: _bottomSheetPlaceholderKey),
key: scaffoldKey,
body: new Center(child: new Text('body'))
);
}
}
));
tester.pump();
expect(showBottomSheetThenCalled, isFalse);
expect(tester.findText('BottomSheet'), isNull);
showBottomSheet(
context: context,
child: new Container(child: new Text('BottomSheet'), margin: new EdgeDims.all(40.0)),
placeholderKey: _bottomSheetPlaceholderKey
).then((_) {
scaffoldKey.currentState.showBottomSheet((BuildContext context) {
return new Container(
margin: new EdgeDims.all(40.0),
child: new Text('BottomSheet')
);
}).closed.then((_) {
showBottomSheetThenCalled = true;
});
expect(_bottomSheetPlaceholderKey.currentState.child, isNotNull);
expect(showBottomSheetThenCalled, isFalse);
expect(tester.findText('BottomSheet'), isNull);
tester.pump(); // bottom sheet show animation starts
expect(showBottomSheetThenCalled, isFalse);
expect(tester.findText('BottomSheet'), isNotNull);
tester.pump(new Duration(seconds: 1)); // animation done
expect(showBottomSheetThenCalled, isFalse);
expect(tester.findText('BottomSheet'), isNotNull);
tester.fling(tester.findText('BottomSheet'), const Offset(0.0, 20.0), 1000.0);
tester.pump(); // drain the microtask queue (Future completion callback)
expect(showBottomSheetThenCalled, isTrue);
expect(tester.findText('BottomSheet'), isNotNull);
tester.pump(); // bottom sheet dismiss animation starts
expect(showBottomSheetThenCalled, isTrue);
expect(tester.findText('BottomSheet'), isNotNull);
tester.pump(new Duration(seconds: 1)); // animation done
tester.pump(new Duration(seconds: 1)); // rebuild frame without the bottom sheet
expect(showBottomSheetThenCalled, isTrue);
expect(tester.findText('BottomSheet'), isNull);
expect(_bottomSheetPlaceholderKey.currentState.child, isNull);
});
});
......
......@@ -162,7 +162,7 @@ class WidgetTester {
assert(velocity != 0.0); // velocity is pixels/second
final TestPointer p = new TestPointer(pointer);
final HitTestResult result = _hitTest(startLocation);
final kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy
const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy
final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * velocity);
double timeStamp = 0.0;
_dispatchEvent(p.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment