Created
July 24, 2025 11:48
-
-
Save pagetronic/88180d9631353aed2a09e5977361068e to your computer and use it in GitHub Desktop.
Contextual help for Flutter
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 '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