Created
September 24, 2024 15:05
-
-
Save lukepighetti/c05472291b16743a6608bbfb452e645f to your computer and use it in GitHub Desktop.
ios style popover view https://x.com/luke_pighetti/status/1838595791075577990
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:async'; | |
import 'dart:convert'; | |
import 'dart:math'; | |
import 'dart:ui'; | |
import 'package:app/components/lifecycle_builder.dart'; | |
import 'package:app/components/button.dart'; | |
import 'package:app/components/cancelling_builder.dart'; | |
import 'package:app/components/keep_any_alive.dart'; | |
import 'package:app/components/listenables_builder.dart'; | |
import 'package:app/components/preview_painter.dart'; | |
import 'package:app/components/popover_view.dart'; | |
import 'package:app/components/rounded_rectangle.dart'; | |
import 'package:app/components/text_input_field.dart'; | |
import 'package:app/components/toast_view.dart'; | |
import 'package:app/components/visibility_builder.dart'; | |
import 'package:app/components/z_stack.dart'; | |
import 'package:app/models.dart'; | |
import 'package:app/styles.dart'; | |
import 'package:app/utils.dart'; | |
import 'package:custom_refresh_indicator/custom_refresh_indicator.dart'; | |
import 'package:flutter/cupertino.dart'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/physics.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:widget_mask/widget_mask.dart'; | |
import 'package:yaml_writer/yaml_writer.dart'; | |
extension ObjectExtensions<T> on T { | |
K let<K>(K Function(T it) fn) => fn(this); | |
} | |
extension WidgetExtensions on Widget { | |
// TODO: consider using @widgetFactory on all Widget extensions | |
// this makes the Widget Inspector click-to-definition on the extension in-use | |
// not the definition below | |
@widgetFactory | |
Widget offset({double x = 0, double y = 0}) { | |
return Transform.translate(offset: Offset(x, y), child: this); | |
} | |
Widget padding({ | |
double all = 10, | |
double x = 0, | |
double y = 0, | |
double top = 0, | |
double right = 0, | |
double bottom = 0, | |
double left = 0, | |
}) { | |
return Padding( | |
padding: EdgeInsets.all(all) + | |
EdgeInsets.symmetric(vertical: y, horizontal: x) + | |
EdgeInsets.fromLTRB(left, top, right, bottom), | |
child: this, | |
); | |
} | |
Widget avoidSafeArea({ | |
double all = 0, | |
double x = 0, | |
double y = 0, | |
double top = 0, | |
double right = 0, | |
double bottom = 0, | |
double left = 0, | |
bool safeTop = true, | |
bool safeLeft = true, | |
bool safeRight = true, | |
bool safeBottom = true, | |
}) { | |
return SafeArea( | |
top: safeTop, | |
left: safeLeft, | |
right: safeRight, | |
bottom: safeBottom, | |
minimum: EdgeInsets.all(all) + | |
EdgeInsets.symmetric(vertical: y, horizontal: x) + | |
EdgeInsets.fromLTRB(left, top, right, bottom), | |
child: this, | |
); | |
} | |
Widget align([Alignment alignment = Alignment.center]) { | |
return Align( | |
alignment: alignment, | |
child: this, | |
); | |
} | |
Widget expand({int flex = 1}) { | |
return Expanded( | |
flex: flex, | |
child: this, | |
); | |
} | |
Widget foregroundColor(Color? color) { | |
return Builder( | |
builder: (context) { | |
return DefaultTextStyle( | |
style: DefaultTextStyle.of(context).style.copyWith(color: color), | |
child: IconTheme( | |
data: IconTheme.of(context).copyWith(color: color), | |
child: this, | |
), | |
); | |
}, | |
); | |
} | |
Widget typeface(Typeface typeface) { | |
return font(typeface: typeface); | |
} | |
Widget textStyle(TextStyle? style, {TextAlign? textAlign}) { | |
return Builder( | |
builder: (context) { | |
final t = DefaultTextStyle.of(context); | |
final merged = t.style.merge(style); | |
return DefaultTextStyle( | |
textAlign: textAlign ?? t.textAlign, | |
style: merged, | |
child: IconTheme( | |
data: IconTheme.of(context).copyWith(size: merged.fontSize), | |
child: this, | |
), | |
); | |
}, | |
); | |
} | |
Widget alignText(TextAlign textAlign) { | |
return textStyle(null, textAlign: textAlign); | |
} | |
Widget font({ | |
Typeface? typeface, | |
FontWeight? weight, | |
double? size, | |
}) { | |
return textStyle(TextStyle( | |
fontSize: size, | |
fontFamily: typeface?.name, | |
fontWeight: weight, | |
)); | |
} | |
Widget onAppear(VoidCallback fn, [double threshold = 0.2]) { | |
return VisibilityBuilder( | |
threshold: threshold, | |
onVisible: (visible) { | |
if (visible) fn(); | |
}, | |
child: this, | |
); | |
} | |
Widget onDisappear(VoidCallback fn, [double threshold = 0.8]) { | |
return VisibilityBuilder( | |
threshold: threshold, | |
onVisible: (visible) { | |
if (!visible) fn(); | |
}, | |
child: this, | |
); | |
} | |
/// Masking widget must have transparency | |
Widget mask(Widget mask) { | |
return WidgetMask( | |
blendMode: BlendMode.srcATop, | |
childSaveLayer: true, | |
mask: mask, | |
child: this, | |
); | |
} | |
Widget border(Color color, {double width = 1.0}) { | |
return DecoratedBox( | |
decoration: BoxDecoration( | |
border: Border.all( | |
color: color, | |
width: width, | |
), | |
), | |
child: this, | |
); | |
} | |
Widget onTap(VoidCallback onTap) { | |
return Button(onTap: onTap, child: this); | |
} | |
Widget refreshable(Future<void> Function() onRefresh) { | |
return CustomRefreshIndicator( | |
onRefresh: onRefresh, | |
completeStateDuration: Duration(milliseconds: 500), | |
builder: MaterialIndicatorDelegate( | |
backgroundColor: Colors.transparent, | |
elevation: 0.0, | |
builder: (context, controller) { | |
return CupertinoActivityIndicator(); | |
}, | |
), | |
child: this, | |
); | |
} | |
Widget opacity(double opacity) { | |
return IgnorePointer( | |
ignoring: opacity == 0.0, | |
child: Opacity( | |
opacity: opacity, | |
child: this, | |
), | |
); | |
} | |
Widget strokeText(Color color, {double width = 2.0}) { | |
// hand tuned for your visual and cpu pleasure | |
final step = lerpAnyDouble(10.0, 0.1, 2.0, 0.4, width).clamp(0.05, 1.0); | |
return textStyle(TextStyle( | |
shadows: [ | |
for (var x = 0.0; x < 2 * pi; x = x + step) | |
Shadow( | |
color: color, | |
offset: Offset(width * sin(x), width * cos(x)), | |
), | |
], | |
)); | |
} | |
Widget keepAlive([bool keepAlive = true]) { | |
return KeepAnyAlive( | |
keepAlive: keepAlive, | |
child: this, | |
); | |
} | |
Widget overlay(Widget child) { | |
return ZStack( | |
[this, child], | |
); | |
} | |
Widget disabled(bool disabled, {bool animate = true}) { | |
return IgnorePointer( | |
ignoring: disabled, | |
child: AnimatedOpacity( | |
duration: animate ? Duration(milliseconds: 250) : Duration.zero, | |
curve: Curves.easeInOut, | |
opacity: disabled ? 0.8 : 1.0, | |
child: this, | |
), | |
); | |
} | |
Widget hidden(bool hidden, | |
{bool animate = true, Duration delay = Duration.zero}) { | |
final duration = Duration(milliseconds: 250); | |
final totalMillis = delay.inMilliseconds + duration.inMilliseconds; | |
return IgnorePointer( | |
ignoring: hidden, | |
child: AnimatedOpacity( | |
duration: animate ? delay + duration : Duration.zero, | |
curve: Interval(delay.inMilliseconds / totalMillis, 1.0), | |
opacity: hidden ? 0.0 : 1.0, | |
child: this, | |
), | |
); | |
} | |
Widget onChange<T>(ValueListenable<T>? notifier, void Function(T value) fn, | |
{bool init = false}) { | |
if (notifier == null) return this; | |
return CancellingBuilder( | |
builder: () => notifier.onChange(fn), | |
child: this, | |
).afterInit(() { | |
if (init) fn(notifier.value); | |
}); | |
} | |
Widget onNotify<T extends ChangeNotifier>( | |
T? notifier, | |
void Function(T value) fn, | |
) { | |
if (notifier == null) return this; | |
return CancellingBuilder( | |
builder: () => notifier.onChange(fn), | |
child: this, | |
); | |
} | |
Widget frame({double? height, double? width}) { | |
return SizedBox(height: height, width: width, child: this); | |
} | |
Widget rotate(double degrees) { | |
return Transform.rotate(angle: degrees / 180 * pi, child: this); | |
} | |
Widget scale(double scale) { | |
return Transform.scale(scale: scale, child: this); | |
} | |
Widget popover(ValueNotifier<bool> isPresented, Widget child) { | |
return PopoverView(isPresented: isPresented, parent: this, child: child); | |
} | |
Widget onInit(VoidCallback fn) { | |
return LifecycleBuilder( | |
onInit: fn, | |
child: this, | |
); | |
} | |
Widget afterInit(VoidCallback fn) { | |
return LifecycleBuilder( | |
afterInit: fn, | |
child: this, | |
); | |
} | |
Widget onDispose(VoidCallback fn) { | |
return LifecycleBuilder( | |
onDispose: fn, | |
child: this, | |
); | |
} | |
Widget highlight([bool test = true]) { | |
return foregroundColor(test ? CupertinoColors.activeBlue : null); | |
} | |
Widget blur(double radius) { | |
return ImageFiltered( | |
imageFilter: ImageFilter.blur(sigmaX: radius, sigmaY: radius), | |
child: this, | |
); | |
} | |
Widget blurBackground(double radius) { | |
return BackdropFilter( | |
filter: ImageFilter.blur(sigmaX: radius, sigmaY: radius), | |
child: this, | |
); | |
} | |
Widget previewPaths(List<Path> paths, {Color color = Colors.red}) { | |
return CustomPaint( | |
foregroundPainter: PreviewPainter.paths(paths, color), | |
child: this, | |
); | |
} | |
Widget previewPoints(List<Offset> points, {Color color = Colors.red}) { | |
return CustomPaint( | |
foregroundPainter: PreviewPainter.points(points, color), | |
child: this, | |
); | |
} | |
Widget previewAlignments(List<Alignment> alignments, | |
{Color color = Colors.red}) { | |
return CustomPaint( | |
foregroundPainter: PreviewPainter.alignments(alignments, color), | |
child: this, | |
); | |
} | |
Widget ignorePointer([bool ignoring = true]) { | |
return IgnorePointer( | |
ignoring: ignoring, | |
child: this, | |
); | |
} | |
Widget toast(ValueNotifier<List<Toast>> toasts) { | |
// TODO: figure out why we need this when [ToastView] has one internally | |
return ListenablesBuilder( | |
listenables: [toasts], | |
builder: (context, _) { | |
return ToastView(toasts: toasts, child: this); | |
}, | |
); | |
} | |
AnnotatedRegion<SystemUiOverlayStyle> darkStatusBar() { | |
return AnnotatedRegion( | |
value: SystemUiOverlayStyle.dark, | |
child: this, | |
); | |
} | |
AnnotatedRegion<SystemUiOverlayStyle> lightStatusBar() { | |
return AnnotatedRegion( | |
value: SystemUiOverlayStyle.light, | |
child: this, | |
); | |
} | |
Widget saturation(double t) { | |
return ColorFiltered( | |
colorFilter: ColorFilter.mode( | |
Colors.grey.withOpacity(1 - t), | |
BlendMode.color, | |
), | |
child: this, | |
); | |
} | |
Widget overlayColor(Color color) { | |
return overlay( | |
SizedBox.expand( | |
child: ColoredBox(color: color), | |
).ignorePointer(), | |
); | |
} | |
} | |
extension TextInputFieldExtensions on TextInputField { | |
Widget styled() { | |
return RoundedRectangle( | |
color: Colors.grey.shade900, | |
child: padding().font(size: 28), | |
).padding(); | |
} | |
Widget password() { | |
return styled() | |
.autofillHints([AutofillHints.password]) | |
.keyboardType(TextInputType.visiblePassword) | |
.obscureText(true) | |
.submitLabel(TextInputAction.next); | |
} | |
Widget email() { | |
return styled() | |
.autofillHints([AutofillHints.email]) | |
.keyboardType(TextInputType.emailAddress) | |
.obscureText(false) | |
.submitLabel(TextInputAction.next); | |
} | |
} | |
extension IterableExtensions<E> on Iterable<E> { | |
Iterable<E> separatedBy(E separator) sync* { | |
final iterator = this.iterator; | |
if (!iterator.moveNext()) return; | |
yield iterator.current; | |
while (iterator.moveNext()) { | |
yield separator; | |
yield iterator.current; | |
} | |
} | |
} | |
extension ListWidgetExtensions on List<Widget> { | |
List<Widget> align(Alignment alignment) { | |
return [ | |
for (final widget in this) | |
Align( | |
alignment: alignment, | |
child: widget, | |
) | |
]; | |
} | |
List<Widget> dividedBy(Widget child) { | |
return separatedBy(child).toList(); | |
} | |
List<Widget> spaced({ | |
double x = 0, | |
double y = 0, | |
}) { | |
return [ | |
for (var i = 0; i < length; i++) | |
if (i == 0 || this[i] is Spacer || this[i] is Expanded) | |
this[i] | |
else | |
Padding( | |
padding: EdgeInsets.only(left: x, top: y), | |
child: this[i], | |
), | |
]; | |
} | |
} | |
extension PageControllerExtensions on PageController { | |
static const _duration = Duration(milliseconds: 500); | |
static const _curve = Curves.ease; | |
double t({required int pages}) { | |
if (!hasClients) { | |
return 0; | |
} else { | |
return (page ?? 0) / (pages - 1); | |
} | |
} | |
Future<void> toNext() async { | |
return nextPage(duration: _duration, curve: _curve); | |
} | |
Future<void> toPrevious() async { | |
return previousPage(duration: _duration, curve: _curve); | |
} | |
Future<void> toPage(int page) async { | |
return animateToPage(page, duration: _duration, curve: _curve); | |
} | |
} | |
extension BoxConstraintsExtensions on BoxConstraints { | |
bool get isBounded => hasBoundedHeight && hasBoundedWidth; | |
Size get bounded { | |
assert(isBounded); | |
return biggest; | |
} | |
} | |
extension NumberExtensions on num { | |
/// Convert degrees to radians | |
double get degrees => this * pi / 180; | |
Duration get seconds => Duration(microseconds: (this * 1e6).toInt()); | |
} | |
extension SizeExtensions on Size { | |
bool containsSize(Size other) { | |
return width >= other.width && height >= other.height; | |
} | |
} | |
extension TextStyleExtensions on TextStyle { | |
Size size(String text, {double maxWidth = double.infinity}) { | |
final textSpan = TextSpan(text: text, style: this); | |
final textPainter = TextPainter( | |
text: textSpan, | |
textDirection: TextDirection.ltr, | |
maxLines: null, | |
textAlign: TextAlign.left, | |
); | |
textPainter.layout(maxWidth: maxWidth); | |
return textPainter.size; | |
} | |
} | |
extension StringExtensions on String { | |
String fractionalSubstring(double t, {bool sliceWords = false}) { | |
if (sliceWords) { | |
final words = split(' '); | |
final sublist = words.sublist(0, lerpDouble(0, words.length, t)!.round()); | |
return sublist.join(" "); | |
} else { | |
return Characters(this) | |
.getRange(0, lerpDouble(0, length, t)!.round()) | |
.join(); | |
} | |
} | |
String fromBase64() { | |
return utf8.decode(base64.decode(this)); | |
} | |
} | |
extension FutureResultExtension<T, E> on Future<Result<T, E>> { | |
Future<T?> get value => then((e) => e.value); | |
Future<E?> get error => then((e) => e.error); | |
} | |
extension ValueListenableExtensions<T> on ValueListenable<T> { | |
Cancellable onChange(void Function(T value) fn) { | |
void handler() { | |
fn(value); | |
} | |
addListener(handler); | |
return Cancellable(() => removeListener(handler)); | |
} | |
Future<T> when(bool Function(T) test) async { | |
final completer = Completer<T>(); | |
final subscription = onChange((value) { | |
if (test(value)) completer.complete(value); | |
}); | |
final value = await completer.future; | |
subscription.cancel(); | |
return value; | |
} | |
Future<T> get next { | |
return when((_) => true); | |
} | |
} | |
extension ValueNotifierListExtension<T> on ValueNotifier<List<T>> { | |
void add(T x) { | |
value = [...value, x]; | |
} | |
void removeLast() { | |
if (value.isEmpty) return; | |
value = [...value]..removeLast(); | |
} | |
void clear() { | |
value = []; | |
} | |
} | |
extension ChangeNotifierExtensions<T extends ChangeNotifier> on T { | |
Cancellable onChange(void Function(T value) fn) { | |
void handler() { | |
fn(this); | |
} | |
addListener(handler); | |
return Cancellable(() => removeListener(handler)); | |
} | |
} | |
extension ListExtensions<T> on List<T> { | |
List<T> sorted([int Function(T a, T b)? compare]) { | |
return [...this]..sort(compare); | |
} | |
int get maxIndex => length - 1; | |
} | |
extension AnimationControllerExtensions on AnimationController { | |
bool get isIdleAtStart { | |
return !isAnimating && value < 0.001; | |
} | |
bool get isIdleAtEnd { | |
return !isAnimating && value > 0.999; | |
} | |
TickerFuture animateWithSpring(double end, [double? vt]) { | |
return animateWith(SpringSimulation( | |
SpringDescription.withDampingRatio( | |
mass: 0.4, | |
stiffness: 100, | |
ratio: 1.0, | |
), | |
value, | |
end, | |
vt ?? velocity, | |
)); | |
} | |
} | |
extension PathMetricExtensions on PathMetric { | |
Tangent? getTangentForPercent(double t) { | |
return getTangentForOffset(t * length); | |
} | |
} | |
extension PathExtensions on Path { | |
Path rotatePath(double degrees, [Alignment alignment = Alignment.center]) { | |
final origin = alignment.withinRect(getBounds()); | |
final matrix4 = Matrix4.identity() | |
..translate(origin.dx, origin.dy) | |
..rotateZ(degrees.degrees) | |
..translate(-origin.dx, -origin.dy); | |
return transform(matrix4.storage); | |
} | |
} | |
extension OffsetExtensions on Offset { | |
Alignment toAlignment(Size size) { | |
final x = dx / size.width * 2 - 1; | |
final y = dy / size.height * 2 - 1; | |
return Alignment(x, y); | |
} | |
} | |
extension MapExtensions<K, V> on Map<K, V> { | |
static final _writer = YAMLWriter(); | |
String toYaml() => _writer.write(this); | |
} | |
extension StringMapExtensions<V> on Map<String, V> { | |
Map<String, dynamic> prefixedWith(String prefix) { | |
return {for (final MapEntry(:key, :value) in entries) '$prefix$key': value}; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'dart:ui'; | |
import 'package:app/components/listenables_builder.dart'; | |
import 'package:app/components/rounded_rectangle.dart'; | |
import 'package:app/components/z_stack.dart'; | |
import 'package:app/extensions.dart'; | |
import 'package:flutter/cupertino.dart'; | |
import 'package:flutter/material.dart'; | |
class PopoverView extends StatefulWidget { | |
const PopoverView({ | |
super.key, | |
required this.parent, | |
required this.child, | |
required this.isPresented, | |
}); | |
final Widget parent; | |
final Widget child; | |
final ValueNotifier<bool> isPresented; | |
@override | |
State<PopoverView> createState() => _PopoverViewState(); | |
} | |
class _PopoverViewState extends State<PopoverView> | |
with SingleTickerProviderStateMixin { | |
// TODO: make declarative fling aware controller | |
late final _animation = AnimationController( | |
value: widget.isPresented.value ? 1.0 : 0.0, | |
vsync: this, | |
); | |
@override | |
void dispose() { | |
_animation.dispose(); | |
super.dispose(); | |
} | |
static const avoidKeyboard = true; | |
@override | |
Widget build(BuildContext context) { | |
final mq = MediaQuery.of(context); | |
final kb = avoidKeyboard ? mq.viewInsets.bottom : 0.0; | |
final vp = mq.viewPadding; | |
// TODO: extract the fling/threshold logic for interactivity reuse | |
// TODO: formalize a layout model for all the parts of this | |
return LayoutBuilder( | |
builder: (context, constraints) { | |
final bounded = constraints.bounded; | |
// The child view being presented | |
final primaryStrokeLength = bounded.height - vp.top; | |
const paddingUnit = 15.0; | |
// The parent view put into the background | |
final scaleTarget = (bounded.width - 4 * paddingUnit) / bounded.width; | |
final primaryHeight = primaryStrokeLength - 3 * paddingUnit; | |
return ListenablesBuilder( | |
listenables: [_animation, widget.isPresented], | |
builder: (context, child) { | |
final t = _animation.value; | |
return GestureDetector( | |
onVerticalDragUpdate: _animation.isIdleAtStart | |
? null | |
: (update) { | |
final dpx = update.primaryDelta ?? 0; | |
final dt = -dpx / primaryStrokeLength; | |
_animation.value += dt; | |
}, | |
onVerticalDragEnd: _animation.isIdleAtStart | |
? null | |
: (end) async { | |
// position based threshold | |
const tThreshold = 0.8; | |
final t = _animation.value; | |
final above = t > tThreshold; | |
// velocity based fling | |
final velocity = end.primaryVelocity ?? 0; | |
const vtThreshold = 2.0; | |
final vt = -velocity / primaryStrokeLength; | |
final fling = vt.abs() > vtThreshold.abs(); | |
final flingUp = velocity.isNegative; | |
if (fling ? flingUp : above) { | |
_animation.animateWithSpring(1, vt); | |
} else { | |
widget.isPresented.value = false; | |
await _animation.animateWithSpring(0); | |
} | |
}, | |
child: ZStack( | |
[ | |
// View behind | |
IgnorePointer( | |
ignoring: !_animation.isIdleAtStart, | |
child: Transform.translate( | |
offset: | |
Offset(0, lerpDouble(0, vp.top + paddingUnit, t)!), | |
child: Transform.scale( | |
scale: lerpDouble(1.0, scaleTarget, t)!, | |
alignment: Alignment.topCenter, | |
child: RoundedRectangle( | |
color: | |
CupertinoColors.darkBackgroundGray.withOpacity(t), | |
radius: lerpDouble(0, 10, t)!, | |
child: widget.parent | |
.saturation(lerpDouble(1.0, 0.3, t)!), | |
), | |
), | |
), | |
), | |
Positioned.fill( | |
child: IgnorePointer( | |
ignoring: _animation.isIdleAtStart, | |
child: GestureDetector( | |
onTap: _animation.isIdleAtStart | |
? null | |
: () async { | |
widget.isPresented.value = false; | |
await _animation.animateWithSpring(0); | |
}, | |
child: ColoredBox( | |
color: | |
Colors.black.withOpacity(lerpDouble(0, 0.2, t)!), | |
), | |
), | |
), | |
), | |
// View in front | |
IgnorePointer( | |
ignoring: _animation.isIdleAtStart, | |
child: RoundedRectangle( | |
radius: 10, | |
color: CupertinoColors.darkBackgroundGray, | |
child: _animation.isIdleAtStart ? null : widget.child, | |
) | |
.padding(all: 0, x: paddingUnit) | |
.font() | |
.frame(height: primaryHeight - max(vp.bottom, kb)) | |
.offset( | |
y: lerpDouble(bounded.height - kb, | |
vp.top + 2 * paddingUnit, t)!, | |
) | |
.align(Alignment.topCenter), | |
), | |
], | |
), | |
); | |
}, | |
); | |
}, | |
).onChange(widget.isPresented, (isPresented) { | |
if (isPresented) { | |
_animation.animateWithSpring(1); | |
} else { | |
_animation.animateWithSpring(0); | |
} | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment