Skip to content

Instantly share code, notes, and snippets.

@pagetronic
Created July 24, 2025 11:48
Show Gist options
  • Save pagetronic/88180d9631353aed2a09e5977361068e to your computer and use it in GitHub Desktop.
Save pagetronic/88180d9631353aed2a09e5977361068e to your computer and use it in GitHub Desktop.
Contextual help for Flutter
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hubd/data/settings.dart';
import 'package:hubd/utils/base/fx.dart';
import 'package:hubd/utils/language.dart';
const String _saveKey = "helps_viewed";
const Duration _duration = Duration(milliseconds: 250);
const Curve _curve = Curves.ease;
const double _padding = 10;
const double _distance = 10;
const Radius _radius = Radius.circular(10);
const double _border = 3;
class HelperItem extends StatelessWidget {
final String? idLang;
final int? index;
final Widget child;
const HelperItem({super.key, this.index, required this.idLang, required this.child});
@override
Widget build(BuildContext context) => child;
}
class HelperGroup extends StatefulWidget {
final Widget child;
const HelperGroup({super.key, required this.child});
@override
State<StatefulWidget> createState() => _HelperGroupState();
static void show(BuildContext context) => context.findAncestorStateOfType<_HelperGroupState>()?.next();
}
class _Help {
final String idLang;
final (Offset northWest, Offset southEast) offsets;
int? index;
_Help(this.index, this.idLang, Element element) : offsets = _getOffsets(element);
static (Offset, Offset) _getOffsets(Element element) {
RenderBox box = element.findRenderObject() as RenderBox;
Rect size = box.paintBounds;
Offset northWest = box.localToGlobal(Offset.zero);
Offset southEast = Offset(northWest.dx + size.width, northWest.dy + size.height);
return (northWest, southEast);
}
}
class _HelperGroupState extends State<HelperGroup> {
final GlobalKey stackKey = GlobalKey();
_Help? help;
int current = -1;
Size? size;
final List<String> helpsViewed = [];
bool viewUnviewedHelp = false;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: LayoutBuilder(
builder: (context, constraints) {
if (help != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Size? newSize = stackKey.currentContext?.size;
if (size != null && size != newSize) {
setState(() {
help = null;
current = -1;
size = null;
});
} else {
size = newSize;
}
});
}
return Stack(
key: stackKey,
fit: StackFit.expand,
children: [
widget.child,
if (help != null) ...[
Positioned.fill(child: _Mask(rect: Rect.fromPoints(help!.offsets.$1, help!.offsets.$2))),
_Bubble(next: next, constraints: constraints, help: help!),
],
],
);
},
),
);
}
void next() {
final List<_Help> helps = getHelps();
helps.sort((a, b) {
int? aIndex = a.index;
int? bIndex = b.index;
if (aIndex == null) {
return -10000;
}
if (bIndex == null) {
return 10000;
}
return aIndex.compareTo(bIndex);
});
if (helps.isEmpty) return;
if (this.help != null) {
viewed(this.help!.idLang);
}
if (viewUnviewedHelp) {
for (_Help help in helps) {
if (!helpsViewed.contains(help.idLang)) {
setState(() {
this.help = help;
});
return;
}
}
if (this.help != null) {
setState(() {
this.help = null;
});
}
viewUnviewedHelp = false;
return;
}
if (++current >= helps.length) {
current = -1;
setState(() {
this.help = null;
});
return;
}
_Help? help = helps.elementAtOrNull(current);
if (help != null) {
viewed(help.idLang);
setState(() {
this.help = help;
});
} else {
next();
}
}
void viewed(String help) {
helpsViewed.addUnique(help);
SettingsStore.get(_saveKey, []).then(
(value) {
List<String> helps = value.cast<String>();
helps.addUnique(help);
SettingsStore.set(_saveKey, helps);
},
);
}
List<_Help> getHelps([BuildContext? context]) {
List<_Help> items = [];
(context ?? this.context).visitChildElements((element) {
if (element.widget is HelperItem) {
HelperItem item = element.widget as HelperItem;
if (item.idLang != null) {
items.add(_Help(item.index, item.idLang!, element));
}
} else {
items.addAll(getHelps(element as BuildContext));
}
});
return items;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
SettingsStore.get(_saveKey, []).then(
(value) {
for (String help in value.cast<String>()) {
helpsViewed.addUnique(help);
}
viewUnviewedHelp = true;
next();
},
);
});
}
}
class _Mask extends StatelessWidget {
final Rect rect;
const _Mask({required this.rect});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) => Stack(
fit: StackFit.expand,
children: [
ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withAlpha(180), BlendMode.srcOut),
child: Stack(
fit: StackFit.expand,
children: [
Container(decoration: BoxDecoration(color: Colors.black, backgroundBlendMode: BlendMode.dstOut)),
AnimatedPositioned.fromRect(
duration: _duration,
curve: _curve,
rect: rect.inflate(_padding).restrict(constraints),
child: Container(decoration: BoxDecoration(color: Colors.black, borderRadius: const BorderRadius.all(_radius))),
),
],
),
),
AnimatedPositioned.fromRect(
duration: _duration,
curve: _curve,
rect: rect.inflate(_padding).restrict(constraints),
child: Container(
decoration: BoxDecoration(border: Border.all(color: Colors.yellow, width: _border), color: Colors.transparent, borderRadius: const BorderRadius.all(_radius)),
),
),
],
),
);
}
}
class _Bubble extends StatelessWidget {
final _Help help;
final BoxConstraints constraints;
final double width = 200;
final void Function() next;
const _Bubble({required this.help, required this.constraints, required this.next});
@override
Widget build(BuildContext context) {
(Offset, Offset, Offset) position = _Positions.get(
context: context,
width: width,
text: Langs.get(help.idLang),
rectView: Rect.fromPoints(help.offsets.$1, help.offsets.$2).inflate(_padding),
globalView: Rect.fromPoints(Offset.zero, Offset(constraints.maxWidth, constraints.maxHeight)),
);
return Listener(
onPointerUp: (event) => next(),
child: Stack(
fit: StackFit.expand,
children: [
_BubbleLine(key: ValueKey(position), position: position),
AnimatedPositioned(
duration: _duration,
curve: _curve,
top: position.$1.dy,
left: position.$1.dx,
child: Container(
width: width,
padding: EdgeInsets.all(5),
decoration: BoxDecoration(
border: Border.all(color: Colors.yellow, width: _border),
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(_radius),
),
child: Text.rich(TextSpan(text: Langs.get(help.idLang)), textDirection: Langs.direction),
),
),
],
),
);
}
}
class _BubbleLine extends StatelessWidget {
final (Offset, Offset, Offset) position;
const _BubbleLine({super.key, required this.position});
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: Container(
color: Colors.transparent,
child: FutureBuilder(
future: Future.delayed(_duration),
builder: (context, snapshot) => AnimatedOpacity(
opacity: snapshot.connectionState == ConnectionState.done ? 1 : 0,
duration: Duration(milliseconds: 10),
child: CustomPaint(
painter: _BubbleLinePainter(position),
),
),
),
),
);
}
}
class _BubbleLinePainter extends CustomPainter {
final (Offset, Offset, Offset) position;
_BubbleLinePainter(this.position);
@override
void paint(Canvas canvas, Size size) {
canvas.drawLine(
position.$2,
position.$3,
Paint()
..color = Colors.yellow
..strokeWidth = _border,
);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
class _Positions {
static (Offset, Offset, Offset) get({
required BuildContext context,
required String text,
required double width,
required Rect globalView,
required Rect rectView,
}) {
TextPainter painter = TextPainter(text: TextSpan(text: text), textDirection: Langs.direction);
painter.layout(maxWidth: width + 10);
double textHeight = painter.height + 10;
Size bubbleSize = Size(width, textHeight);
Offset boxPosition = _Positions._bubblePosition(globalView: globalView, rectView: rectView, bubbleSize: bubbleSize);
Rect bubbleView = Rect.fromLTWH(boxPosition.dx, boxPosition.dy, bubbleSize.width, bubbleSize.height);
(Offset, Offset) arrows = _Positions._nearestOffsets(rectView: RRect.fromRectAndRadius(rectView, _radius), bubbleView: RRect.fromRectAndRadius(bubbleView, _radius));
return (boxPosition, arrows.$1, arrows.$2);
}
static Offset _bubblePosition({required Rect globalView, required Rect rectView, required Size bubbleSize}) {
final List<Offset> potentialOffsets = [];
if (rectView.top - bubbleSize.height - _distance >= globalView.top) {
potentialOffsets.add(Offset(rectView.left + (rectView.width - bubbleSize.width) / 2, rectView.top - bubbleSize.height - _distance));
}
if (rectView.bottom + bubbleSize.height + _distance <= globalView.bottom) {
potentialOffsets.add(Offset(rectView.left + (rectView.width - bubbleSize.width) / 2, rectView.bottom + _distance));
}
if (rectView.left - bubbleSize.width - _distance >= globalView.left) {
potentialOffsets.add(Offset(rectView.left - bubbleSize.width - _distance, rectView.top + (rectView.height - bubbleSize.height) / 2));
}
if (rectView.right + bubbleSize.width + _distance <= globalView.right) {
potentialOffsets.add(Offset(rectView.right + _distance, rectView.top + (rectView.height - bubbleSize.height) / 2));
}
if (rectView.top - bubbleSize.height - _distance >= globalView.top && rectView.left - bubbleSize.width - _distance >= globalView.left) {
potentialOffsets.add(Offset(rectView.left - bubbleSize.width - _distance, rectView.top - bubbleSize.height - _distance));
}
if (rectView.top - bubbleSize.height - _distance >= globalView.top && rectView.right + bubbleSize.width + _distance <= globalView.right) {
potentialOffsets.add(Offset(rectView.right + _distance, rectView.top - bubbleSize.height - _distance));
}
if (rectView.bottom + bubbleSize.height + _distance <= globalView.bottom && rectView.left - bubbleSize.width - _distance >= globalView.left) {
potentialOffsets.add(Offset(rectView.left - bubbleSize.width - _distance, rectView.bottom + _distance));
}
if (rectView.bottom + bubbleSize.height + _distance <= globalView.bottom && rectView.right + bubbleSize.width + _distance <= globalView.right) {
potentialOffsets.add(Offset(rectView.right + _distance, rectView.bottom + _distance));
}
bool containsRect(Rect rect, Rect other) {
return rect.left <= other.left && rect.top <= other.top && rect.right >= other.right && rect.bottom >= other.bottom;
}
final validOffsets = potentialOffsets.where((offset) {
final bubbleRect = Rect.fromLTWH(offset.dx, offset.dy, bubbleSize.width, bubbleSize.height);
return containsRect(globalView, bubbleRect) && !bubbleRect.overlaps(rectView);
}).toList();
return validOffsets.isNotEmpty ? validOffsets.last : Offset(globalView.left, globalView.top);
}
static (Offset, Offset) _nearestOffsets({required RRect rectView, required RRect bubbleView}) {
if (rectView.outerRect.overlaps(bubbleView.outerRect)) {
Rect intersection = rectView.outerRect.intersect(bubbleView.outerRect);
return (intersection.center, intersection.center);
}
Offset projectPointOnRRect(Offset point, RRect rrect) {
if (rrect.contains(point)) {
return point;
}
Offset tlCenter = Offset(rrect.left + rrect.tlRadius.x, rrect.top + rrect.tlRadius.y);
Offset trCenter = Offset(rrect.right - rrect.trRadius.x, rrect.top + rrect.trRadius.y);
Offset blCenter = Offset(rrect.left + rrect.blRadius.x, rrect.bottom - rrect.blRadius.y);
Offset brCenter = Offset(rrect.right - rrect.brRadius.x, rrect.bottom - rrect.brRadius.y);
Offset projectToCornerArc(Offset point, Offset cornerCenter, Radius radius) {
if (radius.x == 0.0 || radius.y == 0.0) return cornerCenter;
double dx = point.dx - cornerCenter.dx;
double dy = point.dy - cornerCenter.dy;
double t = 1.0 / sqrt(pow(dx / radius.x, 2) + pow(dy / radius.y, 2));
return Offset(cornerCenter.dx + dx * t, cornerCenter.dy + dy * t);
}
if (point.dx < tlCenter.dx && point.dy < tlCenter.dy) {
return projectToCornerArc(point, tlCenter, rrect.tlRadius);
}
if (point.dx > trCenter.dx && point.dy < trCenter.dy) {
return projectToCornerArc(point, trCenter, rrect.trRadius);
}
if (point.dx < blCenter.dx && point.dy > blCenter.dy) {
return projectToCornerArc(point, blCenter, rrect.blRadius);
}
if (point.dx > brCenter.dx && point.dy > brCenter.dy) {
return projectToCornerArc(point, brCenter, rrect.brRadius);
}
return Offset(
point.dx.clamp(rrect.left, rrect.right),
point.dy.clamp(rrect.top, rrect.bottom),
);
}
Offset pointOnBubble = projectPointOnRRect(rectView.center, bubbleView);
Offset pointOnView = projectPointOnRRect(pointOnBubble, rectView);
return (pointOnView, pointOnBubble);
}
}
extension on Rect {
Rect restrict(BoxConstraints constraints) {
Offset topLeft = this.topLeft;
Offset bottomRight = this.bottomRight;
if (top < 5) {
topLeft = Offset(topLeft.dx, 5);
}
if (left < 5) {
topLeft = Offset(5, topLeft.dy);
}
if (right > constraints.maxWidth - 5) {
bottomRight = Offset(constraints.maxWidth - 5, bottomRight.dy);
}
if (bottom > constraints.maxHeight - 5) {
bottomRight = Offset(bottomRight.dx, constraints.maxHeight - 5);
}
return Rect.fromPoints(topLeft, bottomRight);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment