feat/fix/refactor: адекватный UX чата существует

This commit is contained in:
Jganenokk
2026-08-15 20:44:13 +07:00
parent cf9297b0ab
commit d91759a1cf
11 changed files with 963 additions and 364 deletions
+337 -175
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -10,34 +11,64 @@ import '../../core/utils/haptics.dart';
import '../../l10n/app_localizations.dart';
import 'custom_notification.dart';
RenderParagraph? _findParagraph(RenderObject? ro) {
if (ro == null) return null;
if (ro is RenderParagraph) return ro;
RenderParagraph? found;
ro.visitChildren((child) {
found ??= _findParagraph(child);
});
return found;
void _collectParagraphs(RenderObject ro, List<RenderParagraph> out) {
if (ro is RenderParagraph) {
out.add(ro);
return;
}
ro.visitChildren((child) => _collectParagraphs(child, out));
}
bool _isSpace(int c) =>
c == 0x20 ||
c == 0x09 ||
c == 0x0A ||
c == 0x0D ||
c == 0x0C ||
c == 0xA0;
c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D || c == 0x0C || c == 0xA0;
bool _isGlyphOnly(String text) {
if (text.isEmpty) return true;
for (final rune in text.runes) {
final private =
(rune >= 0xE000 && rune <= 0xF8FF) ||
(rune >= 0xF0000 && rune <= 0xFFFFD) ||
(rune >= 0x100000 && rune <= 0x10FFFD);
if (!private) return false;
}
return true;
}
class _ParaSlice {
final RenderParagraph rp;
final int start;
final String text;
const _ParaSlice({required this.rp, required this.start, required this.text});
int get end => start + text.length;
bool get usable => rp.attached && rp.hasSize;
Offset get origin => rp.localToGlobal(Offset.zero);
Rect get globalRect => origin & rp.size;
TextSelection? clip(TextSelection selection) {
final s = selection.start.clamp(start, end) - start;
final e = selection.end.clamp(start, end) - start;
if (e <= s) return null;
return TextSelection(baseOffset: s, extentOffset: e);
}
}
class SelectableMessageText extends StatefulWidget {
final Widget child;
final Offset initialGlobalPosition;
final VoidCallback onExit;
final ValueListenable<Offset?>? dragPosition;
const SelectableMessageText({
super.key,
required this.child,
required this.initialGlobalPosition,
required this.onExit,
this.dragPosition,
});
@override
@@ -53,30 +84,111 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
static const double _hitBelow = 42.0;
final GlobalKey _textKey = GlobalKey();
final LayerLink _link = LayerLink();
final ValueNotifier<bool> _toolbarVisible = ValueNotifier(false);
late final AnimationController _entrance;
OverlayEntry? _overlay;
Timer? _settle;
TextSelection _selection = const TextSelection.collapsed(offset: 0);
String? _cachedText;
bool _dragging = false;
bool _exiting = false;
double? _dragStartLocalY;
double? _dragStartGlobalY;
double _dragAnchorY = 0;
double _dragLineHeight = 0;
RenderParagraph? _cachedParagraph;
List<_ParaSlice>? _cachedSlices;
String _cachedJoined = '';
ScrollPosition? _scrollPosition;
TextSelection? _anchor;
RenderParagraph? get _paragraph {
final cached = _cachedParagraph;
if (cached != null && cached.attached) return cached;
return _cachedParagraph = _findParagraph(
_textKey.currentContext?.findRenderObject(),
);
List<_ParaSlice> get _slices {
final cached = _cachedSlices;
if (cached != null && cached.isNotEmpty && cached.every((s) => s.usable)) {
return cached;
}
final root = _textKey.currentContext?.findRenderObject();
final paragraphs = <RenderParagraph>[];
if (root != null) _collectParagraphs(root, paragraphs);
final slices = <_ParaSlice>[];
var offset = 0;
for (final rp in paragraphs) {
if (!rp.attached || !rp.hasSize) continue;
final text = rp.text.toPlainText();
if (_isGlyphOnly(text)) continue;
slices.add(_ParaSlice(rp: rp, start: offset, text: text));
offset += text.length + 1;
}
_cachedJoined = slices.map((s) => s.text).join('\n');
return _cachedSlices = slices;
}
String _text(RenderParagraph rp) => _cachedText ??= rp.text.toPlainText();
String get _joined {
_slices;
return _cachedJoined;
}
_ParaSlice? _sliceAt(Offset globalPos) {
final slices = _slices;
if (slices.isEmpty) return null;
_ParaSlice? nearest;
var best = double.infinity;
for (final slice in slices) {
final rect = slice.globalRect;
if (rect.contains(globalPos)) return slice;
final dy = globalPos.dy < rect.top
? rect.top - globalPos.dy
: globalPos.dy - rect.bottom;
final distance = math.max(0.0, dy);
if (distance < best) {
best = distance;
nearest = slice;
}
}
return nearest;
}
int? _offsetAt(Offset globalPos) {
final slice = _sliceAt(globalPos);
if (slice == null) return null;
final local = slice.rp.globalToLocal(globalPos);
final off = slice.rp
.getPositionForOffset(local)
.offset
.clamp(0, slice.text.length);
return slice.start + off;
}
RenderBox? get _rootBox {
final ro = _textKey.currentContext?.findRenderObject();
if (ro is! RenderBox || !ro.attached || !ro.hasSize) return null;
return ro;
}
List<Rect> _localBoxes(TextSelection selection) {
if (!selection.isValid || selection.isCollapsed) return const [];
final root = _rootBox;
if (root == null) return const [];
final rootOrigin = root.localToGlobal(Offset.zero);
final out = <Rect>[];
for (final slice in _slices) {
final local = slice.clip(selection);
if (local == null) continue;
final delta = slice.origin - rootOrigin;
for (final box in slice.rp.getBoxesForSelection(local)) {
out.add(box.toRect().shift(delta));
}
}
return out;
}
List<Rect> _globalBoxes(TextSelection selection) {
final root = _rootBox;
if (root == null) return const [];
final rootOrigin = root.localToGlobal(Offset.zero);
return [for (final rect in _localBoxes(selection)) rect.shift(rootOrigin)];
}
@override
void initState() {
@@ -85,12 +197,57 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
vsync: this,
duration: const Duration(milliseconds: 260),
)..addListener(() => _overlay?.markNeedsBuild());
widget.dragPosition?.addListener(_onDragPosition);
WidgetsBinding.instance.addPostFrameCallback((_) => _init(4));
}
@override
void didUpdateWidget(SelectableMessageText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.dragPosition == widget.dragPosition) return;
oldWidget.dragPosition?.removeListener(_onDragPosition);
widget.dragPosition?.addListener(_onDragPosition);
}
void _onDragPosition() {
if (!mounted) return;
final pos = widget.dragPosition?.value;
if (pos == null) {
if (_anchor != null) _toolbarVisible.value = true;
return;
}
final anchor = _anchor;
if (anchor == null) return;
final off = _offsetAt(pos);
if (off == null) return;
_toolbarVisible.value = false;
if (off > anchor.end) {
_applySelection(
TextSelection(baseOffset: anchor.start, extentOffset: off),
);
} else if (off < anchor.start) {
_applySelection(TextSelection(baseOffset: off, extentOffset: anchor.end));
} else {
_applySelection(anchor);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final position = Scrollable.maybeOf(context)?.position;
if (identical(position, _scrollPosition)) return;
_scrollPosition?.removeListener(_onScroll);
_scrollPosition = position?..addListener(_onScroll);
}
void _onScroll() => _overlay?.markNeedsBuild();
@override
void dispose() {
_settle?.cancel();
widget.dragPosition?.removeListener(_onDragPosition);
_scrollPosition?.removeListener(_onScroll);
_entrance.dispose();
_overlay?.remove();
_overlay = null;
@@ -100,8 +257,7 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
void _init(int retries) {
if (!mounted) return;
final rp = _paragraph;
if (rp == null || !rp.hasSize) {
if (_slices.isEmpty) {
if (retries > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) => _init(retries - 1));
} else {
@@ -109,7 +265,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
}
return;
}
_selectWordAt(widget.initialGlobalPosition, rp);
_selectWordAt(widget.initialGlobalPosition);
_anchor = _selection;
Haptics.selection();
_ensureOverlay();
_toolbarVisible.value = true;
@@ -130,10 +287,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
});
}
TextSelection _normalize(int a, int b) => TextSelection(
baseOffset: math.min(a, b),
extentOffset: math.max(a, b),
);
TextSelection _normalize(int a, int b) =>
TextSelection(baseOffset: math.min(a, b), extentOffset: math.max(a, b));
void _applySelection(TextSelection sel, {bool animate = false}) {
_selection = sel;
@@ -142,12 +297,11 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
_overlay?.markNeedsBuild();
}
TextRange _wordRange(RenderParagraph rp, Offset globalPos) {
final text = _text(rp);
TextRange _wordRange(Offset globalPos) {
final text = _joined;
final len = text.length;
if (len == 0) return const TextRange.collapsed(0);
final local = rp.globalToLocal(globalPos);
var off = rp.getPositionForOffset(local).offset.clamp(0, len);
var off = (_offsetAt(globalPos) ?? 0).clamp(0, len);
bool ws(int i) => i < 0 || i >= len || _isSpace(text.codeUnitAt(i));
if (ws(off) && off > 0 && !ws(off - 1)) off -= 1;
if (ws(off)) return TextRange.collapsed(off);
@@ -162,72 +316,59 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
return TextRange(start: s, end: e);
}
void _selectWordAt(Offset globalPos, RenderParagraph rp) {
final range = _wordRange(rp, globalPos);
void _selectWordAt(Offset globalPos) {
final range = _wordRange(globalPos);
if (range.isCollapsed) {
_applySelection(_normalize(0, _text(rp).length), animate: true);
_applySelection(_normalize(0, _joined.length), animate: true);
} else {
_applySelection(_normalize(range.start, range.end), animate: true);
}
}
void _onBackgroundTap(Offset globalPos) {
final rp = _paragraph;
if (rp == null || !rp.hasSize) {
final slices = _slices;
if (slices.isEmpty) {
_requestExit();
return;
}
final local = rp.globalToLocal(globalPos);
if (rp.size.contains(local)) {
final inside = slices.any((s) => s.globalRect.contains(globalPos));
if (inside) {
_dragging = false;
_settle?.cancel();
_selectWordAt(globalPos, rp);
_toolbarVisible.value = true;
} else {
_requestExit();
}
}
ui.TextBox? _edgeBox(RenderParagraph rp, bool isStart) {
if (!_selection.isValid || _selection.isCollapsed) return null;
final boxes = rp.getBoxesForSelection(_selection);
if (boxes.isEmpty) return null;
return isStart ? boxes.first : boxes.last;
}
void _onHandleDragStart(Offset globalPos, bool isStart) {
_dragging = true;
_settle?.cancel();
_entrance.value = 1.0;
_toolbarVisible.value = false;
_dragStartLocalY = null;
final rp = _paragraph;
if (rp == null || !rp.hasSize) return;
final box = _edgeBox(rp, isStart);
if (box == null) return;
final rect = box.toRect();
_dragStartLocalY = rp.globalToLocal(globalPos).dy;
_dragStartGlobalY = null;
final boxes = _globalBoxes(_selection);
if (boxes.isEmpty) return;
final rect = isStart ? boxes.first : boxes.last;
_dragStartGlobalY = globalPos.dy;
_dragAnchorY = rect.center.dy;
_dragLineHeight = rect.height;
}
double _lineSnappedY(double fingerLocalY) {
final startY = _dragStartLocalY;
if (startY == null || _dragLineHeight <= 0) return fingerLocalY;
final dragged = fingerLocalY - startY;
double _lineSnappedY(double fingerGlobalY) {
final startY = _dragStartGlobalY;
if (startY == null || _dragLineHeight <= 0) return fingerGlobalY;
final dragged = fingerGlobalY - startY;
final direction = dragged < 0 ? -1 : 1;
final lines = direction * (dragged.abs() / _dragLineHeight).floor();
return _dragAnchorY + lines * _dragLineHeight;
}
void _onHandleDrag(Offset globalPos, bool isStart) {
final rp = _paragraph;
if (rp == null || !rp.hasSize) return;
final len = _text(rp).length;
final local = rp.globalToLocal(globalPos);
final off = rp
.getPositionForOffset(Offset(local.dx, _lineSnappedY(local.dy)))
.offset;
final len = _joined.length;
if (len == 0) return;
final off = _offsetAt(Offset(globalPos.dx, _lineSnappedY(globalPos.dy)));
if (off == null) return;
if (isStart) {
final ns = off.clamp(0, math.max(0, _selection.end - 1)).toInt();
_applySelection(
@@ -243,7 +384,7 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
void _onHandleDragEnd() {
_dragging = false;
_dragStartLocalY = null;
_dragStartGlobalY = null;
_settle?.cancel();
_settle = Timer(const Duration(milliseconds: 140), () {
if (!mounted || _dragging) return;
@@ -253,9 +394,8 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
}
void _copy() {
final rp = _paragraph;
if (rp != null && _selection.isValid && !_selection.isCollapsed) {
final text = _text(rp);
if (_selection.isValid && !_selection.isCollapsed) {
final text = _joined;
final sub = text.substring(
_selection.start.clamp(0, text.length),
_selection.end.clamp(0, text.length),
@@ -273,48 +413,53 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
}
void _selectAll() {
final rp = _paragraph;
if (rp == null) return;
if (_slices.isEmpty) return;
Haptics.tap();
_applySelection(_normalize(0, _text(rp).length), animate: true);
_applySelection(_normalize(0, _joined.length), animate: true);
_toolbarVisible.value = true;
}
Widget _buildOverlay(BuildContext ctx) {
final rp = _paragraph;
if (rp == null || !rp.hasSize || !rp.attached) {
return const SizedBox.shrink();
}
if (_slices.isEmpty) return const SizedBox.shrink();
final cs = Theme.of(ctx).colorScheme;
final List<ui.TextBox> boxes =
(_selection.isValid && !_selection.isCollapsed)
? rp.getBoxesForSelection(_selection)
: const [];
final rects = _localBoxes(_selection);
final children = <Widget>[
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
behavior: HitTestBehavior.translucent,
onTapUp: (d) => _onBackgroundTap(d.globalPosition),
),
),
];
if (boxes.isNotEmpty) {
final first = boxes.first.toRect();
final last = boxes.last.toRect();
final startBottom = rp.localToGlobal(Offset(first.left, first.bottom));
final endBottom = rp.localToGlobal(Offset(last.right, last.bottom));
if (rects.isNotEmpty) {
final first = rects.first;
final last = rects.last;
children.add(_handle(cs, startBottom, isStart: true));
children.add(_handle(cs, endBottom, isStart: false));
children.add(_toolbar(ctx, rp, first, last));
children.add(
_handle(cs, Offset(first.left, first.bottom), isStart: true),
);
children.add(
_handle(cs, Offset(last.right, last.bottom), isStart: false),
);
children.add(_toolbar(ctx, first, last));
}
return Stack(children: children);
}
Widget _follow(Offset offset, Widget child) => Positioned(
left: 0,
top: 0,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
offset: offset,
child: child,
),
);
Widget _handle(
ColorScheme cs,
Offset lineBottomGlobal, {
@@ -325,41 +470,42 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
lineBottomGlobal.dy + _ballRadius,
);
final leftInset = isStart ? _hitOuter : _hitInner;
return Positioned(
left: center.dx - leftInset,
top: center.dy - _hitAbove,
width: _hitInner + _hitOuter,
height: _hitAbove + _hitBelow,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart),
onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart),
onPanEnd: (_) => _onHandleDragEnd(),
onPanCancel: _onHandleDragEnd,
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: EdgeInsets.only(
left: leftInset - _ballRadius,
top: _hitAbove - _ballRadius,
),
child: Transform.scale(
scale: Curves.easeOutBack.transform(
_entrance.value.clamp(0.0, 1.0),
return _follow(
Offset(center.dx - leftInset, center.dy - _hitAbove),
SizedBox(
width: _hitInner + _hitOuter,
height: _hitAbove + _hitBelow,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (d) => _onHandleDragStart(d.globalPosition, isStart),
onPanUpdate: (d) => _onHandleDrag(d.globalPosition, isStart),
onPanEnd: (_) => _onHandleDragEnd(),
onPanCancel: _onHandleDragEnd,
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: EdgeInsets.only(
left: leftInset - _ballRadius,
top: _hitAbove - _ballRadius,
),
child: Container(
width: _ballRadius * 2,
height: _ballRadius * 2,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
child: Transform.scale(
scale: Curves.easeOutBack.transform(
_entrance.value.clamp(0.0, 1.0),
),
child: Container(
width: _ballRadius * 2,
height: _ballRadius * 2,
decoration: BoxDecoration(
color: cs.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
),
@@ -369,34 +515,36 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
);
}
Widget _toolbar(BuildContext ctx, RenderParagraph rp, Rect first, Rect last) {
Widget _toolbar(BuildContext ctx, Rect first, Rect last) {
final media = MediaQuery.of(ctx);
final size = media.size;
final rootOrigin = _rootBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final safeTop = media.padding.top + 8;
final safeBottom = size.height - media.padding.bottom - 8;
const height = 48.0;
const gap = 10.0;
final topGlobal = rp.localToGlobal(first.topLeft).dy;
final bottomGlobal = rp.localToGlobal(Offset(last.right, last.bottom)).dy;
double top = topGlobal - gap - height;
if (top < safeTop) top = bottomGlobal + gap;
double top = rootOrigin.dy + first.top - gap - height;
if (top < safeTop) top = rootOrigin.dy + last.bottom + gap;
top = top.clamp(safeTop, math.max(safeTop, safeBottom - height));
return Positioned(
left: 12,
right: 12,
top: top,
child: ValueListenableBuilder<bool>(
valueListenable: _toolbarVisible,
builder: (ctx, visible, _) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Center(child: _pill(ctx)),
return _follow(
Offset(-rootOrigin.dx, top - rootOrigin.dy),
SizedBox(
width: size.width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ValueListenableBuilder<bool>(
valueListenable: _toolbarVisible,
builder: (ctx, visible, _) => IgnorePointer(
ignoring: !visible,
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: Center(child: _pill(ctx)),
),
),
),
),
),
@@ -447,28 +595,34 @@ class _SelectableMessageTextState extends State<SelectableMessageText>
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return CustomPaint(
painter: _HighlightPainter(
paragraph: _paragraph,
selection: _selection,
animation: _entrance,
fill: cs.primary.withValues(alpha: 0.28),
stem: cs.primary,
return CompositedTransformTarget(
link: _link,
child: CustomPaint(
painter: _HighlightPainter(
rootKey: _textKey,
slices: _slices,
selection: _selection,
animation: _entrance,
fill: cs.primary.withValues(alpha: 0.28),
stem: cs.primary,
),
child: KeyedSubtree(key: _textKey, child: widget.child),
),
child: KeyedSubtree(key: _textKey, child: widget.child),
);
}
}
class _HighlightPainter extends CustomPainter {
final RenderParagraph? paragraph;
final GlobalKey rootKey;
final List<_ParaSlice> slices;
final TextSelection selection;
final Animation<double> animation;
final Color fill;
final Color stem;
_HighlightPainter({
required this.paragraph,
required this.rootKey,
required this.slices,
required this.selection,
required this.animation,
required this.fill,
@@ -478,28 +632,36 @@ class _HighlightPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
if (!selection.isValid || selection.isCollapsed) return;
final rp = paragraph;
if (rp == null || !rp.hasSize || !rp.attached) return;
final boxes = rp.getBoxesForSelection(selection);
if (boxes.isEmpty) return;
final root = rootKey.currentContext?.findRenderObject();
if (root is! RenderBox || !root.attached || !root.hasSize) return;
final rootOrigin = root.localToGlobal(Offset.zero);
final rects = <Rect>[];
for (final slice in slices) {
if (!slice.usable) continue;
final local = slice.clip(selection);
if (local == null) continue;
final delta = slice.origin - rootOrigin;
for (final box in slice.rp.getBoxesForSelection(local)) {
rects.add(box.toRect().shift(delta));
}
}
if (rects.isEmpty) return;
final t = animation.value.clamp(0.0, 1.0);
final eased = Curves.easeOut.transform(t);
final grow = 0.72 + 0.28 * eased;
final fillPaint = Paint()..color = fill.withValues(alpha: fill.a * eased);
for (final box in boxes) {
final rect = box.toRect().inflate(0.5);
final cy = rect.center.dy;
final h = rect.height * grow;
final animRect = Rect.fromLTRB(
rect.left,
cy - h / 2,
rect.right,
cy + h / 2,
);
for (final rect in rects) {
final inflated = rect.inflate(0.5);
final cy = inflated.center.dy;
final h = inflated.height * grow;
canvas.drawRRect(
RRect.fromRectAndRadius(animRect, const Radius.circular(3)),
RRect.fromRectAndRadius(
Rect.fromLTRB(inflated.left, cy - h / 2, inflated.right, cy + h / 2),
const Radius.circular(3),
),
fillPaint,
);
}
@@ -508,8 +670,8 @@ class _HighlightPainter extends CustomPainter {
..color = stem.withValues(alpha: stem.a * eased)
..strokeWidth = 2.5
..strokeCap = StrokeCap.round;
final first = boxes.first.toRect();
final last = boxes.last.toRect();
final first = rects.first;
final last = rects.last;
canvas.drawLine(
Offset(first.left, first.top),
Offset(first.left, first.bottom),
@@ -525,7 +687,7 @@ class _HighlightPainter extends CustomPainter {
@override
bool shouldRepaint(_HighlightPainter old) =>
old.selection != selection ||
old.paragraph != paragraph ||
old.slices.length != slices.length ||
old.fill != fill ||
old.stem != stem;
}