feat(chat): add global pinch-to-zoom text scaling via ChatTextScaleService

This commit is contained in:
just_stuff_tm 2026-02-23 22:41:32 -05:00
parent 64bf307d09
commit 0f17e2382c
5 changed files with 247 additions and 92 deletions

View file

@ -15,6 +15,7 @@ import 'services/ble_debug_log_service.dart';
import 'services/app_debug_log_service.dart';
import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
@ -34,6 +35,7 @@ void main() async {
final appDebugLogService = AppDebugLogService();
final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService();
// Load settings
await appSettingsService.loadSettings();
@ -50,6 +52,8 @@ void main() async {
await backgroundService.initialize();
_registerThirdPartyLicenses();
await chatTextScaleService.initialize();
// Wire up connector with services
connector.initialize(
retryService: retryService,
@ -78,6 +82,7 @@ void main() async {
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService,
),
);
}
@ -112,6 +117,7 @@ class MeshCoreApp extends StatelessWidget {
final BleDebugLogService bleDebugLogService;
final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService;
const MeshCoreApp({
super.key,
@ -123,6 +129,7 @@ class MeshCoreApp extends StatelessWidget {
required this.bleDebugLogService,
required this.appDebugLogService,
required this.mapTileCacheService,
required this.chatTextScaleService,
});
@override
@ -135,6 +142,7 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService),
Provider.value(value: storage),
Provider.value(value: mapTileCacheService),
],

View file

@ -18,7 +18,9 @@ import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../utils/emoji_utils.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
@ -219,37 +221,50 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Stack(
children: [
ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.all(8),
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
ChatZoomWrapper(
child: ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.all(8),
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
);
}
final messageIndex = index;
final message = reversedMessages[messageIndex];
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
return Container(
key: _messageKeys[message.messageId]!,
child: Builder(
builder: (context) {
final textScale = context
.select<ChatTextScaleService, double>(
(service) => service.scale,
);
return _buildMessageBubble(
message,
textScale,
);
},
),
);
}
final messageIndex = index;
final message = reversedMessages[messageIndex];
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
return Container(
key: _messageKeys[message.messageId]!,
child: _buildMessageBubble(message),
);
},
},
),
),
JumpToBottomButton(scrollController: _scrollController),
],
@ -264,7 +279,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
Widget _buildMessageBubble(ChannelMessage message) {
Widget _buildMessageBubble(ChannelMessage message, double textScale) {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
@ -278,6 +293,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
const maxSwipeOffset = 64.0;
const replySwipeThreshold = 64.0;
const bodyFontSize = 14.0;
final messageBody = Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
@ -334,7 +350,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
_buildReplyPreview(message, textScale),
const SizedBox(height: 8),
],
if (poi != null)
@ -342,6 +358,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
context,
poi,
isOutgoing,
textScale,
trailing: (!enableTracing && isOutgoing)
? Padding(
padding: const EdgeInsets.only(bottom: 2),
@ -415,9 +432,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Flexible(
child: Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
fontSize: 14,
style: TextStyle(
fontSize: bodyFontSize * textScale,
),
linkStyle: TextStyle(
fontSize: bodyFontSize * textScale,
color: Colors.green,
decoration: TextDecoration.underline,
),
@ -595,7 +614,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
Widget _buildReplyPreview(ChannelMessage message) {
Widget _buildReplyPreview(ChannelMessage message, double textScale) {
final connector = context.read<MeshCoreConnector>();
final isOwnNode = message.replyToSenderName == connector.selfName;
final replyText = message.replyToText ?? '';
@ -623,7 +642,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
const SizedBox(width: 4),
Text(
context.l10n.chat_location,
style: TextStyle(fontSize: 12, color: previewTextColor),
style: TextStyle(fontSize: 12 * textScale, color: previewTextColor),
),
],
);
@ -633,7 +652,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontSize: 12 * textScale,
color: previewTextColor,
fontStyle: FontStyle.italic,
),
@ -657,7 +676,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Text(
context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
style: TextStyle(
fontSize: 11,
fontSize: 11 * textScale,
fontWeight: FontWeight.bold,
color: isOwnNode
? Theme.of(context).colorScheme.primary
@ -736,7 +755,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildPoiMessage(
BuildContext context,
_PoiInfo poi,
bool isOutgoing, {
bool isOutgoing,
double textScale, {
Widget? trailing,
}) {
final colorScheme = Theme.of(context).colorScheme;
@ -774,12 +794,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
context.l10n.chat_poiShared,
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
fontSize: 14 * textScale,
),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(color: metaColor, fontSize: 12),
style: TextStyle(color: metaColor, fontSize: 12 * textScale),
),
],
),
@ -849,7 +873,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return colors[hash.abs() % colors.length];
}
Widget _buildReplyBanner() {
Widget _buildReplyBanner(double textScale) {
final message = _replyingToMessage!;
return Container(
width: double.infinity,
@ -875,7 +899,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Text(
context.l10n.chat_replyingTo(message.senderName),
style: TextStyle(
fontSize: 12,
fontSize: 12 * textScale,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
@ -885,7 +909,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
@ -912,7 +936,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_replyingToMessage != null) _buildReplyBanner(),
if (_replyingToMessage != null)
Builder(
builder: (context) {
final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale,
);
return _buildReplyBanner(textScale);
},
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(

View file

@ -22,7 +22,9 @@ import '../models/contact.dart';
import '../models/message.dart';
import '../models/path_history.dart';
import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/path_history_service.dart';
import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/elements_ui.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
@ -270,52 +272,62 @@ class _ChatScreenState extends State<ChatScreen> {
_scrollController.scrollToBottomIfAtBottom();
});
return ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
return ChatZoomWrapper(
child: ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
itemCount: itemCount,
itemBuilder: (context, index) {
// Loading indicator now appears at end (bottom) of reversed list
if (_isLoadingOlder && index == itemCount - 1) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
);
}
final messageIndex = index;
Contact contact = widget.contact;
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty
? Uint8List.fromList([0, 0, 0, 0])
: message.fourByteRoomContactKey,
);
fourByteHex = message.fourByteRoomContactKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
);
}
final messageIndex = index;
Contact contact = widget.contact;
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty
? Uint8List.fromList([0, 0, 0, 0])
: message.fourByteRoomContactKey,
);
fourByteHex = message.fourByteRoomContactKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
return _MessageBubble(
message: message,
senderName: widget.contact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
: contact.name,
isRoomServer: widget.contact.type == advTypeRoom,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
);
},
return Builder(
builder: (context) {
final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale,
);
return _MessageBubble(
message: message,
senderName: widget.contact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
: contact.name,
isRoomServer: widget.contact.type == advTypeRoom,
textScale: textScale,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
);
},
);
},
),
);
}
@ -1163,11 +1175,13 @@ class _MessageBubble extends StatelessWidget {
final bool isRoomServer;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final double textScale;
const _MessageBubble({
required this.message,
required this.senderName,
required this.isRoomServer,
required this.textScale,
this.onTap,
this.onLongPress,
});
@ -1190,6 +1204,7 @@ class _MessageBubble extends StatelessWidget {
? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
final metaColor = textColor.withValues(alpha: 0.7);
const bodyFontSize = 14.0;
String messageText = message.text;
if (isRoomServer && !isOutgoing) {
messageText = message.text.substring(4.clamp(0, message.text.length));
@ -1258,6 +1273,7 @@ class _MessageBubble extends StatelessWidget {
poi,
textColor,
metaColor,
textScale,
trailing: (!enableTracing && isOutgoing)
? Padding(
padding: const EdgeInsets.only(bottom: 2),
@ -1321,10 +1337,14 @@ class _MessageBubble extends StatelessWidget {
Flexible(
child: Linkify(
text: messageText,
style: TextStyle(color: textColor),
linkStyle: const TextStyle(
style: TextStyle(
color: textColor,
fontSize: bodyFontSize * textScale,
),
linkStyle: TextStyle(
color: Colors.green,
decoration: TextDecoration.underline,
fontSize: bodyFontSize * textScale,
),
options: const LinkifyOptions(
humanize: false,
@ -1464,7 +1484,8 @@ class _MessageBubble extends StatelessWidget {
BuildContext context,
_PoiInfo poi,
Color textColor,
Color metaColor, {
Color metaColor,
double textScale, {
Widget? trailing,
}) {
return Row(
@ -1493,12 +1514,16 @@ class _MessageBubble extends StatelessWidget {
children: [
Text(
context.l10n.chat_poiShared,
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
fontSize: 14 * textScale,
),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(color: metaColor, fontSize: 12),
style: TextStyle(color: metaColor, fontSize: 12 * textScale),
),
],
),

View file

@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import '../storage/prefs_manager.dart';
/// Client-side accessibility/UI service that exposes a persistent shared text scale
/// factor. No MeshCoreConnector/RoomServer or protocol interaction occurs, and the
/// value is saved locally via SharedPreferences so it can be reused in Markdown
/// viewers, log panels, or other text-heavy widgets without redundant network
/// dependencies.
///
/// Widgets should scope rebuilds using the snippet below so only the scaled text
/// is rebuilt instead of the entire chat list:
/// ```dart
/// context.select<ChatTextScaleService, double>(
/// (service) => service.scale,
/// )
/// ```
class ChatTextScaleService extends ChangeNotifier {
static const _prefKey = 'chat_text_scale';
static const double _minScale = 0.8;
static const double _maxScale = 1.8;
double _scale = 1.0;
double get scale => _scale;
Future<void> initialize() async {
final stored = PrefsManager.instance.getDouble(_prefKey);
if (stored != null) {
_scale = _clamp(stored);
}
}
void setScale(double value) {
final next = _clamp(value);
if (next == _scale) return;
_scale = next;
PrefsManager.instance.setDouble(_prefKey, _scale);
notifyListeners();
}
void reset() => setScale(1.0);
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
}

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/chat_text_scale_service.dart';
/// Gesture wrapper that exposes two-finger pinch-to-zoom for chat scrollables.
/// Double-tap resets the scale. Only the wrapper itself listens to gestures;
/// child scrollables keep their normal touch handling.
class ChatZoomWrapper extends StatelessWidget {
ChatZoomWrapper({super.key, required this.child, this.onDoubleTap});
final Widget child;
final VoidCallback? onDoubleTap;
final _ZoomGestureState _state = _ZoomGestureState();
@override
Widget build(BuildContext context) {
final service = context.read<ChatTextScaleService>();
return GestureDetector(
behavior: HitTestBehavior.translucent,
onDoubleTap: () {
service.reset();
onDoubleTap?.call();
},
onScaleStart: (details) {
if (details.pointerCount != 2) return;
_state.startScale = service.scale;
},
onScaleUpdate: (details) {
if (details.pointerCount != 2) return;
final baseScale = _state.startScale ?? service.scale;
service.setScale(baseScale * details.scale);
},
onScaleEnd: (_) {
_state.startScale = null;
},
child: child,
);
}
}
class _ZoomGestureState {
double? startScale;
}