Merge pull request #302 from cogwheel0/improve-middle-ellipsis-text-handling
fix(widget): Improve MiddleEllipsisText Unicode and surrogate handling
This commit is contained in:
@@ -343,20 +343,17 @@ class VoiceCallService {
|
|||||||
|
|
||||||
// Set up periodic keep-alive to refresh wake lock (every 5 minutes)
|
// Set up periodic keep-alive to refresh wake lock (every 5 minutes)
|
||||||
_keepAliveTimer?.cancel();
|
_keepAliveTimer?.cancel();
|
||||||
_keepAliveTimer = Timer.periodic(
|
_keepAliveTimer = Timer.periodic(const Duration(minutes: 5), (_) async {
|
||||||
const Duration(minutes: 5),
|
final success = await BackgroundStreamingHandler.instance.keepAlive();
|
||||||
(_) async {
|
if (!success) {
|
||||||
final success = await BackgroundStreamingHandler.instance.keepAlive();
|
// Keep-alive failed but don't stop the call - service may still work
|
||||||
if (!success) {
|
developer.log(
|
||||||
// Keep-alive failed but don't stop the call - service may still work
|
'Voice call keep-alive failed',
|
||||||
developer.log(
|
name: 'VoiceCallService',
|
||||||
'Voice call keep-alive failed',
|
level: 900, // WARNING
|
||||||
name: 'VoiceCallService',
|
);
|
||||||
level: 900, // WARNING
|
}
|
||||||
);
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up socket event listener for assistant responses
|
// Set up socket event listener for assistant responses
|
||||||
_socketSubscription = _socketService.addChatEventHandler(
|
_socketSubscription = _socketService.addChatEventHandler(
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
/// A single-line text widget that truncates the middle of long strings
|
/// A single-line text widget that truncates the middle of long strings
|
||||||
/// with an ellipsis (e.g., "prefix…suffix") so both ends remain visible.
|
/// with an ellipsis (e.g., "prefix…suffix") so both ends remain visible.
|
||||||
|
///
|
||||||
|
/// This widget handles Unicode text safely, including emojis and other
|
||||||
|
/// characters that span multiple UTF-16 code units (surrogate pairs).
|
||||||
class MiddleEllipsisText extends StatelessWidget {
|
class MiddleEllipsisText extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final TextStyle? style;
|
final TextStyle? style;
|
||||||
@@ -20,6 +23,9 @@ class MiddleEllipsisText extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Sanitize text to remove any unpaired surrogates that could cause crashes.
|
||||||
|
final String safeText = _sanitizeUtf16(text);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final TextStyle effectiveStyle = DefaultTextStyle.of(
|
final TextStyle effectiveStyle = DefaultTextStyle.of(
|
||||||
@@ -29,7 +35,7 @@ class MiddleEllipsisText extends StatelessWidget {
|
|||||||
final double maxWidth = constraints.maxWidth;
|
final double maxWidth = constraints.maxWidth;
|
||||||
|
|
||||||
// Measure full text width first.
|
// Measure full text width first.
|
||||||
final fullSpan = TextSpan(text: text, style: effectiveStyle);
|
final fullSpan = TextSpan(text: safeText, style: effectiveStyle);
|
||||||
final fullPainter = TextPainter(
|
final fullPainter = TextPainter(
|
||||||
text: fullSpan,
|
text: fullSpan,
|
||||||
textDirection: direction,
|
textDirection: direction,
|
||||||
@@ -38,7 +44,7 @@ class MiddleEllipsisText extends StatelessWidget {
|
|||||||
|
|
||||||
if (fullPainter.width <= maxWidth) {
|
if (fullPainter.width <= maxWidth) {
|
||||||
return Text(
|
return Text(
|
||||||
text,
|
safeText,
|
||||||
style: effectiveStyle,
|
style: effectiveStyle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.clip,
|
overflow: TextOverflow.clip,
|
||||||
@@ -47,6 +53,11 @@ class MiddleEllipsisText extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use grapheme clusters (Characters) to safely split text without
|
||||||
|
// breaking surrogate pairs or emoji sequences.
|
||||||
|
final characters = safeText.characters;
|
||||||
|
final int totalGraphemes = characters.length;
|
||||||
|
|
||||||
// Pre-measure ellipsis width (used implicitly during search).
|
// Pre-measure ellipsis width (used implicitly during search).
|
||||||
final ellipsisSpan = TextSpan(text: ellipsis, style: effectiveStyle);
|
final ellipsisSpan = TextSpan(text: ellipsis, style: effectiveStyle);
|
||||||
final ellipsisPainter = TextPainter(
|
final ellipsisPainter = TextPainter(
|
||||||
@@ -56,24 +67,25 @@ class MiddleEllipsisText extends StatelessWidget {
|
|||||||
)..layout(minWidth: 0, maxWidth: double.infinity);
|
)..layout(minWidth: 0, maxWidth: double.infinity);
|
||||||
final double _ = ellipsisPainter.width; // hint width; not used directly
|
final double _ = ellipsisPainter.width; // hint width; not used directly
|
||||||
|
|
||||||
// Binary search the maximum number of visible characters (k), split
|
// Binary search the maximum number of visible graphemes (k), split
|
||||||
// between start and end. For a given k, we use ceil(k/2) from start
|
// between start and end. For a given k, we use ceil(k/2) from start
|
||||||
// and floor(k/2) from end.
|
// and floor(k/2) from end.
|
||||||
int low = 0;
|
int low = 0;
|
||||||
int high = text.length; // exclusive upper bound in practice
|
int high = totalGraphemes;
|
||||||
int bestK = 0;
|
int bestK = 0;
|
||||||
String bestStart = '';
|
String bestStart = '';
|
||||||
String bestEnd = '';
|
String bestEnd = '';
|
||||||
|
|
||||||
while (low <= high) {
|
while (low <= high) {
|
||||||
final int k = (low + high) >> 1; // candidate visible char count
|
final int k = (low + high) >> 1; // candidate visible grapheme count
|
||||||
final int leftCount = (k + 1) >> 1; // ceil(k/2)
|
final int leftCount = (k + 1) >> 1; // ceil(k/2)
|
||||||
final int rightCount = k - leftCount; // floor(k/2)
|
final int rightCount = k - leftCount; // floor(k/2)
|
||||||
|
|
||||||
final String start = text.substring(0, leftCount);
|
// Use Characters.take/takeLast to safely extract grapheme clusters.
|
||||||
|
final String start = characters.take(leftCount).toString();
|
||||||
final String end = rightCount == 0
|
final String end = rightCount == 0
|
||||||
? ''
|
? ''
|
||||||
: text.substring(text.length - rightCount);
|
: characters.takeLast(rightCount).toString();
|
||||||
|
|
||||||
final trialSpan = TextSpan(
|
final trialSpan = TextSpan(
|
||||||
text: '$start$ellipsis$end',
|
text: '$start$ellipsis$end',
|
||||||
@@ -102,7 +114,7 @@ class MiddleEllipsisText extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.clip,
|
overflow: TextOverflow.clip,
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
semanticsLabel: semanticsLabel ?? text,
|
semanticsLabel: semanticsLabel ?? safeText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +125,48 @@ class MiddleEllipsisText extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.clip,
|
overflow: TextOverflow.clip,
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
semanticsLabel: semanticsLabel ?? text,
|
semanticsLabel: semanticsLabel ?? safeText,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes unpaired UTF-16 surrogates that would cause "not well-formed
|
||||||
|
/// UTF-16" errors during text layout.
|
||||||
|
///
|
||||||
|
/// A valid UTF-16 string requires:
|
||||||
|
/// - High surrogates (0xD800-0xDBFF) must be followed by low surrogates
|
||||||
|
/// - Low surrogates (0xDC00-0xDFFF) must be preceded by high surrogates
|
||||||
|
static String _sanitizeUtf16(String input) {
|
||||||
|
if (input.isEmpty) return input;
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (int i = 0; i < input.length; i++) {
|
||||||
|
final int codeUnit = input.codeUnitAt(i);
|
||||||
|
|
||||||
|
// Check if this is a high surrogate (0xD800-0xDBFF)
|
||||||
|
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
|
||||||
|
// Check if next character is a valid low surrogate
|
||||||
|
if (i + 1 < input.length) {
|
||||||
|
final int nextCodeUnit = input.codeUnitAt(i + 1);
|
||||||
|
if (nextCodeUnit >= 0xDC00 && nextCodeUnit <= 0xDFFF) {
|
||||||
|
// Valid surrogate pair - include both
|
||||||
|
buffer.writeCharCode(codeUnit);
|
||||||
|
buffer.writeCharCode(nextCodeUnit);
|
||||||
|
i++; // Skip the low surrogate in next iteration
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unpaired high surrogate - replace with replacement character
|
||||||
|
buffer.writeCharCode(0xFFFD);
|
||||||
|
} else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) {
|
||||||
|
// Unpaired low surrogate - replace with replacement character
|
||||||
|
buffer.writeCharCode(0xFFFD);
|
||||||
|
} else {
|
||||||
|
// Regular character - include as-is
|
||||||
|
buffer.writeCharCode(codeUnit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user