Skip to content

Instantly share code, notes, and snippets.

@hnvn
Last active May 7, 2024 15:35
Show Gist options
  • Save hnvn/f1094fb4f6902078516cba78de9c868e to your computer and use it in GitHub Desktop.
Save hnvn/f1094fb4f6902078516cba78de9c868e to your computer and use it in GitHub Desktop.
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:async';
void main() => runApp(new MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Flutter Demo',
theme: new ThemeData(
primarySwatch: Colors.blue,
),
home: new MyHomePage(title: 'Flip Animation'),
);
}
}
class MyHomePage extends StatelessWidget {
final digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9,];
final String title;
MyHomePage({this.title});
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text(title),
),
body: new Center(
child: FlipPanel.builder(
itemBuilder: (context, index) => Container(
alignment: Alignment.center,
width: 96.0,
height: 128.0,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.all(Radius.circular(4.0)),
),
child: Text(
'${digits[index]}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 80.0,
color: Colors.yellow),
),
),
itemsCount: digits.length,
period: Duration(milliseconds: 1000),
loop: -1,
),
),
);
}
}
/// Signature for a function that creates a widget for a given index, e.g., in a
/// list.
typedef Widget IndexedItemBuilder(BuildContext, int);
/// Signature for a function that creates a widget for a value emitted from a [Stream]
typedef Widget StreamItemBuilder<T>(BuildContext, T);
/// A widget for flip panel with built-in animation
/// Content of the panel is built from [IndexedItemBuilder] or [StreamItemBuilder]
///
/// Note: the content size should be equal
enum FlipDirection { up, down }
class FlipPanel<T> extends StatefulWidget {
final IndexedItemBuilder indexedItemBuilder;
final StreamItemBuilder<T> streamItemBuilder;
final Stream<T> itemStream;
final int itemsCount;
final Duration period;
final Duration duration;
final int loop;
final int startIndex;
final T initValue;
final double spacing;
final FlipDirection direction;
FlipPanel({
Key key,
this.indexedItemBuilder,
this.streamItemBuilder,
this.itemStream,
this.itemsCount,
this.period,
this.duration,
this.loop,
this.startIndex,
this.initValue,
this.spacing,
this.direction,
}) : super(key: key);
/// Create a flip panel from iterable source
/// [itemBuilder] is called periodically in each time of [period]
/// The animation is looped in [loop] times before finished.
/// Setting [loop] to -1 makes flip animation run forever.
/// The [period] should be two times greater than [duration] of flip animation,
/// if not the animation becomes jerky/stuttery.
FlipPanel.builder({
Key key,
@required IndexedItemBuilder itemBuilder,
@required this.itemsCount,
@required this.period,
this.duration = const Duration(milliseconds: 500),
this.loop = 1,
this.startIndex = 0,
this.spacing = 0.5,
this.direction = FlipDirection.up,
}) : assert(itemBuilder != null),
assert(itemsCount != null),
assert(startIndex < itemsCount),
assert(period == null ||
period.inMilliseconds >= 2 * duration.inMilliseconds),
indexedItemBuilder = itemBuilder,
streamItemBuilder = null,
itemStream = null,
initValue = null,
super(key: key);
/// Create a flip panel from stream source
/// [itemBuilder] is called whenever a new value is emitted from [itemStream]
FlipPanel.stream({
Key key,
@required this.itemStream,
@required StreamItemBuilder<T> itemBuilder,
this.initValue,
this.duration = const Duration(milliseconds: 500),
this.spacing = 0.5,
this.direction = FlipDirection.up,
}) : assert(itemStream != null),
indexedItemBuilder = null,
streamItemBuilder = itemBuilder,
itemsCount = 0,
period = null,
loop = 0,
startIndex = 0,
super(key: key);
@override
_FlipPanelState<T> createState() => _FlipPanelState<T>();
}
class _FlipPanelState<T> extends State<FlipPanel>
with TickerProviderStateMixin {
AnimationController _controller;
Animation _animation;
int _currentIndex;
bool _isReversePhase;
bool _isStreamMode;
bool _running;
final _perspective = 0.003;
final _zeroAngle = 0.0001; // There's something wrong in the perspective transform, I use a very small value instead of zero to temporarily get it around.
int _loop;
T _currentValue, _nextValue;
Timer _timer;
StreamSubscription<T> _subscription;
Widget _child1, _child2;
Widget _upperChild1, _upperChild2;
Widget _lowerChild1, _lowerChild2;
@override
void initState() {
super.initState();
_currentIndex = widget.startIndex;
_isStreamMode = widget.itemStream != null;
_isReversePhase = false;
_running = false;
_loop = 0;
_controller =
new AnimationController(duration: widget.duration, vsync: this)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_isReversePhase = true;
_controller.reverse();
}
if (status == AnimationStatus.dismissed) {
_currentValue = _nextValue;
_running = false;
}
})
..addListener(() {
setState(() {
_running = true;
});
});
_animation = Tween(begin: _zeroAngle, end: math.pi / 2).animate(_controller);
if (widget.period != null) {
_timer = Timer.periodic(widget.period, (_) {
if (widget.loop < 0 || _loop < widget.loop) {
if (_currentIndex + 1 == widget.itemsCount - 2) {
_loop++;
}
_currentIndex = (_currentIndex + 1) % widget.itemsCount;
_child1 = null;
_isReversePhase = false;
_controller.forward();
} else {
_timer.cancel();
_currentIndex = (_currentIndex + 1) % widget.itemsCount;
setState(() {
_running = false;
});
}
});
}
if (_isStreamMode) {
_currentValue = widget.initValue;
_subscription = widget.itemStream.distinct().listen((value) {
if (_currentValue == null) {
_currentValue = value;
} else if (value != _currentValue) {
_nextValue = value;
_child1 = null;
_isReversePhase = false;
_controller.forward();
}
});
} else if (widget.loop < 0 || _loop < widget.loop) {
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
if (_subscription != null) _subscription.cancel();
if (_timer != null) _timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
_buildChildWidgetsIfNeed(context);
return _buildPanel();
}
void _buildChildWidgetsIfNeed(BuildContext context) {
Widget makeUpperClip(Widget widget) {
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: 0.5,
child: widget,
),
);
}
Widget makeLowerClip(Widget widget) {
return ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: 0.5,
child: widget,
),
);
}
if (_running) {
if (_child1 == null) {
_child1 = _child2 != null
? _child2
: _isStreamMode
? widget.streamItemBuilder(context, _currentValue)
: widget.indexedItemBuilder(
context, _currentIndex % widget.itemsCount);
_child2 = null;
_upperChild1 =
_upperChild2 != null ? _upperChild2 : makeUpperClip(_child1);
_lowerChild1 =
_lowerChild2 != null ? _lowerChild2 : makeLowerClip(_child1);
}
if (_child2 == null) {
_child2 = _isStreamMode
? widget.streamItemBuilder(context, _nextValue)
: widget.indexedItemBuilder(
context, (_currentIndex + 1) % widget.itemsCount);
_upperChild2 = makeUpperClip(_child2);
_lowerChild2 = makeLowerClip(_child2);
}
} else {
_child1 = _child2 != null
? _child2
: _isStreamMode
? widget.streamItemBuilder(context, _currentValue)
: widget.indexedItemBuilder(
context, _currentIndex % widget.itemsCount);
_upperChild1 =
_upperChild2 != null ? _upperChild2 : makeUpperClip(_child1);
_lowerChild1 =
_lowerChild2 != null ? _lowerChild2 : makeLowerClip(_child1);
}
}
Widget _buildUpperFlipPanel() => widget.direction == FlipDirection.up
? Stack(
children: [
Transform(
alignment: Alignment.bottomCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_zeroAngle),
child: _upperChild1
),
Transform(
alignment: Alignment.bottomCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_isReversePhase ? _animation.value : math.pi / 2),
child: _upperChild2,
),
],
)
: Stack(
children: [
Transform(
alignment: Alignment.bottomCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_zeroAngle),
child: _upperChild2
),
Transform(
alignment: Alignment.bottomCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_isReversePhase ? math.pi / 2 : _animation.value),
child: _upperChild1,
),
],
);
Widget _buildLowerFlipPanel() => widget.direction == FlipDirection.up
? Stack(
children: [
Transform(
alignment: Alignment.topCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_zeroAngle),
child: _lowerChild2
),
Transform(
alignment: Alignment.topCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_isReversePhase ? math.pi / 2 : -_animation.value),
child: _lowerChild1,
)
],
)
: Stack(
children: [
Transform(
alignment: Alignment.topCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_zeroAngle),
child: _lowerChild1
),
Transform(
alignment: Alignment.topCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_isReversePhase ? -_animation.value : math.pi / 2),
child: _lowerChild2,
)
],
);
Widget _buildPanel() {
return _running
? Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildUpperFlipPanel(),
Padding(
padding: EdgeInsets.only(top: widget.spacing),
),
_buildLowerFlipPanel(),
],
)
: _isStreamMode && _currentValue == null
? Container()
: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Transform(
alignment: Alignment.bottomCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_zeroAngle),
child: _upperChild1
),
Padding(
padding: EdgeInsets.only(top: widget.spacing),
),
Transform(
alignment: Alignment.topCenter,
transform: Matrix4.identity()
..setEntry(3, 2, _perspective)
..rotateX(_zeroAngle),
child: _lowerChild1
)
],
);
}
}
@hunter2009pf
Copy link

awesome coding

@PrakTech
Copy link

PrakTech commented Apr 12, 2021

😍 🥰 Thanks for the Code its 😊 amazing 🤟 you do but for me How does it work.
I got a lot of error 🥵 in doing so i have update flutter to latest version 2.04.I tried to replace with @required and ? nullable type, but could not solved.
👹 error: The parameter 'title' can't have a value of 'null' because of its type, but the implicit default value is 'null'. (missing_default_value_for_parameter at lib/flutter_flip_animation_4.dart:25)
There is miss mached type '<'T'>' with '<'dynamic'>' type.

Copy link

ghost commented Sep 4, 2021

salut, je viens d'essayer le code mais ca ne marche pas ... avec beaucoup de rouges!!!!

@satoshi Nakamoto

@DonZheng
Copy link

Can you please make this code null safety?

@sanskritkm
Copy link

Can you please make this code null safety?

ClipRect class is nullsafty ready.

Here

@Mohammadhamza43
Copy link

Mohammadhamza43 commented Jul 20, 2022

@DonZheng

import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:async';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flip Animation'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  final digits = [
    0,
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    8,
    9,
  ];
  final String title;

  MyHomePage({Key? key, required this.title}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: FlipPanel.builder(
          itemBuilder: (context, index) => Container(
            alignment: Alignment.center,
            width: 96.0,
            height: 128.0,
            decoration: const BoxDecoration(
              color: Colors.black,
              borderRadius: BorderRadius.all(Radius.circular(4.0)),
            ),
            child: Text(
              '${digits[index]}',
              style: const TextStyle(
                  fontWeight: FontWeight.bold,
                  fontSize: 80.0,
                  color: Colors.yellow),
            ),
          ),
          itemsCount: digits.length,
          period: const Duration(milliseconds: 1000),
          loop: -1,
        ),
      ),
    );
  }
}

/// Signature for a function that creates a widget for a given index, e.g., in a
/// list.
typedef Widget IndexedItemBuilder(BuildContext, int);

/// Signature for a function that creates a widget for a value emitted from a [Stream]
typedef Widget StreamItemBuilder<T>(BuildContext, T);

/// A widget for flip panel with built-in animation
/// Content of the panel is built from [IndexedItemBuilder] or [StreamItemBuilder]
///
/// Note: the content size should be equal
enum FlipDirection { up, down }

class FlipPanel<T> extends StatefulWidget {
  final IndexedItemBuilder? indexedItemBuilder;
  final StreamItemBuilder<T>? streamItemBuilder;
  final Stream<T>? itemStream;
  final int? itemsCount;
  final Duration? period;
  final Duration? duration;
  final int? loop;
  final int? startIndex;
  final T? initValue;
  final double? spacing;
  final FlipDirection? direction;

  const FlipPanel({
    Key? key,
    this.indexedItemBuilder,
    this.streamItemBuilder,
    this.itemStream,
    this.itemsCount,
    this.period,
    this.duration,
    this.loop,
    this.startIndex,
    this.initValue,
    this.spacing,
    this.direction,
  }) : super(key: key);

  /// Create a flip panel from iterable source
  /// [itemBuilder] is called periodically in each time of [period]
  /// The animation is looped in [loop] times before finished.
  /// Setting [loop] to -1 makes flip animation run forever.
  /// The [period] should be two times greater than [duration] of flip animation,
  /// if not the animation becomes jerky/stuttery.
  FlipPanel.builder({
    Key? key,
    required IndexedItemBuilder itemBuilder,
    required this.itemsCount,
    required this.period,
    this.duration = const Duration(milliseconds: 500),
    this.loop = 1,
    this.startIndex = 0,
    this.spacing = 0.5,
    this.direction = FlipDirection.up,
  })  : assert(itemBuilder != null),
        assert(itemsCount != null),
        assert(startIndex! < itemsCount!),
        assert(period == null ||
            period.inMilliseconds >= 2 * duration!.inMilliseconds),
        indexedItemBuilder = itemBuilder,
        streamItemBuilder = null,
        itemStream = null,
        initValue = null,
        super(key: key);

  /// Create a flip panel from stream source
  /// [itemBuilder] is called whenever a new value is emitted from [itemStream]
  const FlipPanel.stream({
    Key? key,
    required this.itemStream,
    required StreamItemBuilder<T> itemBuilder,
    this.initValue,
    this.duration = const Duration(milliseconds: 500),
    this.spacing = 0.5,
    this.direction = FlipDirection.up,
  })  : assert(itemStream != null),
        indexedItemBuilder = null,
        streamItemBuilder = itemBuilder,
        itemsCount = 0,
        period = null,
        loop = 0,
        startIndex = 0,
        super(key: key);

  @override
  _FlipPanelState<T> createState() => _FlipPanelState<T>();
}

class _FlipPanelState<T> extends State<FlipPanel>
    with TickerProviderStateMixin {
  AnimationController? _controller;
  Animation? _animation;
  int? _currentIndex;
  bool? _isReversePhase;
  bool? _isStreamMode;
  bool? _running;
  final _perspective = 0.003;
  final _zeroAngle =
      0.0001; // There's something wrong in the perspective transform, I use a very small value instead of zero to temporarily get it around.
  int? _loop;
  T? _currentValue, _nextValue;
  Timer? _timer;
  StreamSubscription<T>? _subscription;

  Widget? _child1, _child2;
  Widget? _upperChild1, _upperChild2;
  Widget? _lowerChild1, _lowerChild2;

  @override
  void initState() {
    super.initState();
    _currentIndex = widget.startIndex;
    _isStreamMode = widget.itemStream != null;
    _isReversePhase = false;
    _running = false;
    _loop = 0;

    _controller = AnimationController(duration: widget.duration, vsync: this)
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _isReversePhase = true;
          _controller!.reverse();
        }
        if (status == AnimationStatus.dismissed) {
          _currentValue = _nextValue;
          _running = false;
        }
      })
      ..addListener(() {
        setState(() {
          _running = true;
        });
      });
    _animation =
        Tween(begin: _zeroAngle, end: math.pi / 2).animate(_controller!);

    if (widget.period != null) {
      _timer = Timer.periodic(widget.period!, (_) {
        if (widget.loop! < 0 || _loop! < widget.loop!) {
          if (_currentIndex! + 1 == widget.itemsCount! - 2) {
            _loop = _loop! + 1;
          }
          _currentIndex = (_currentIndex! + 1) % widget.itemsCount!;
          _child1 = null;
          _isReversePhase = false;
          _controller!.forward();
        } else {
          _timer!.cancel();
          _currentIndex = (_currentIndex! + 1) % widget.itemsCount!;
          setState(() {
            _running = false;
          });
        }
      });
    }

    if (_isStreamMode!) {
      _currentValue = widget.initValue;
      _subscription = widget.itemStream!.distinct().listen((value) {
        if (_currentValue == null) {
          _currentValue = value;
        } else if (value != _currentValue) {
          _nextValue = value;
          _child1 = null;
          _isReversePhase = false;
          _controller!.forward();
        }
      }) as StreamSubscription<T>?;
    } else if (widget.loop! < 0 || _loop! < widget.loop!) {
      _controller!.forward();
    }
  }

  @override
  void dispose() {
    _controller!.dispose();
    if (_subscription != null) _subscription!.cancel();
    if (_timer != null) _timer!.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    _buildChildWidgetsIfNeed(context);

    return _buildPanel();
  }

  void _buildChildWidgetsIfNeed(BuildContext context) {
    Widget makeUpperClip(Widget widget) {
      return ClipRect(
        child: Align(
          alignment: Alignment.topCenter,
          heightFactor: 0.5,
          child: widget,
        ),
      );
    }

    Widget makeLowerClip(Widget widget) {
      return ClipRect(
        child: Align(
          alignment: Alignment.bottomCenter,
          heightFactor: 0.5,
          child: widget,
        ),
      );
    }

    if (_running!) {
      if (_child1 == null) {
        if (_child2 != null) {
          _child1 = _child2;
        } else {
          _child1 = _isStreamMode!
              ? widget.streamItemBuilder!(context, _currentValue)
              : widget.indexedItemBuilder!(
                  context, _currentIndex! % widget.itemsCount!);
        }
        _child2 = null;
        _upperChild1 =
            _upperChild2 != null ? _upperChild2 : makeUpperClip(_child1!);
        _lowerChild1 =
            _lowerChild2 != null ? _lowerChild2 : makeLowerClip(_child1!);
      }
      if (_child2 == null) {
        _child2 = _isStreamMode!
            ? widget.streamItemBuilder!(context, _nextValue)
            : widget.indexedItemBuilder!(
                context, (_currentIndex! + 1) % widget.itemsCount!);
        _upperChild2 = makeUpperClip(_child2!);
        _lowerChild2 = makeLowerClip(_child2!);
      }
    } else {
      if (_child2 != null) {
        _child1 = _child2;
      } else {
        _child1 = _isStreamMode!
            ? widget.streamItemBuilder!(context, _currentValue)
            : widget.indexedItemBuilder!(
                context, _currentIndex! % widget.itemsCount!);
      }
      _upperChild1 =
          _upperChild2 != null ? _upperChild2 : makeUpperClip(_child1!);
      _lowerChild1 =
          _lowerChild2 != null ? _lowerChild2 : makeLowerClip(_child1!);
    }
  }

  Widget _buildUpperFlipPanel() => widget.direction == FlipDirection.up
      ? Stack(
          children: [
            Transform(
                alignment: Alignment.bottomCenter,
                transform: Matrix4.identity()
                  ..setEntry(3, 2, _perspective)
                  ..rotateX(_zeroAngle),
                child: _upperChild1),
            Transform(
              alignment: Alignment.bottomCenter,
              transform: Matrix4.identity()
                ..setEntry(3, 2, _perspective)
                ..rotateX(_isReversePhase! ? _animation!.value : math.pi / 2),
              child: _upperChild2,
            ),
          ],
        )
      : Stack(
          children: [
            Transform(
                alignment: Alignment.bottomCenter,
                transform: Matrix4.identity()
                  ..setEntry(3, 2, _perspective)
                  ..rotateX(_zeroAngle),
                child: _upperChild2),
            Transform(
              alignment: Alignment.bottomCenter,
              transform: Matrix4.identity()
                ..setEntry(3, 2, _perspective)
                ..rotateX(_isReversePhase! ? math.pi / 2 : _animation!.value),
              child: _upperChild1,
            ),
          ],
        );

  Widget _buildLowerFlipPanel() => widget.direction == FlipDirection.up
      ? Stack(
          children: [
            Transform(
                alignment: Alignment.topCenter,
                transform: Matrix4.identity()
                  ..setEntry(3, 2, _perspective)
                  ..rotateX(_zeroAngle),
                child: _lowerChild2),
            Transform(
              alignment: Alignment.topCenter,
              transform: Matrix4.identity()
                ..setEntry(3, 2, _perspective)
                ..rotateX(_isReversePhase! ? math.pi / 2 : -_animation!.value),
              child: _lowerChild1,
            )
          ],
        )
      : Stack(
          children: [
            Transform(
                alignment: Alignment.topCenter,
                transform: Matrix4.identity()
                  ..setEntry(3, 2, _perspective)
                  ..rotateX(_zeroAngle),
                child: _lowerChild1),
            Transform(
              alignment: Alignment.topCenter,
              transform: Matrix4.identity()
                ..setEntry(3, 2, _perspective)
                ..rotateX(_isReversePhase! ? -_animation!.value : math.pi / 2),
              child: _lowerChild2,
            )
          ],
        );

  Widget _buildPanel() {
    return _running!
        ? Column(
            mainAxisSize: MainAxisSize.min,
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              _buildUpperFlipPanel(),
              Padding(
                padding: EdgeInsets.only(top: widget.spacing!),
              ),
              _buildLowerFlipPanel(),
            ],
          )
        : _isStreamMode! && _currentValue == null
            ? Container()
            : Column(
                mainAxisSize: MainAxisSize.min,
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  Transform(
                      alignment: Alignment.bottomCenter,
                      transform: Matrix4.identity()
                        ..setEntry(3, 2, _perspective)
                        ..rotateX(_zeroAngle),
                      child: _upperChild1),
                  Padding(
                    padding: EdgeInsets.only(top: widget.spacing!),
                  ),
                  Transform(
                      alignment: Alignment.topCenter,
                      transform: Matrix4.identity()
                        ..setEntry(3, 2, _perspective)
                        ..rotateX(_zeroAngle),
                      child: _lowerChild1)
                ],
              );
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment