Skip to content

Instantly share code, notes, and snippets.

@lukepighetti
Created September 24, 2024 15:05
Show Gist options
  • Save lukepighetti/c05472291b16743a6608bbfb452e645f to your computer and use it in GitHub Desktop.
Save lukepighetti/c05472291b16743a6608bbfb452e645f to your computer and use it in GitHub Desktop.
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};
}
}
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