diff --git a/lib/components/layout/device_bottom_bar.dart b/lib/components/layout/device_bottom_bar.dart index 729fc6c..ea5147d 100644 --- a/lib/components/layout/device_bottom_bar.dart +++ b/lib/components/layout/device_bottom_bar.dart @@ -3,15 +3,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/theme/color_tokens.dart'; +import '../../core/utils/smooth_scroll_controller.dart'; import '../../models/device_info.dart'; import '../../server/providers/server_providers.dart'; /// Bottom bar showing connected devices with glow animation on selected item. -class DeviceBottomBar extends ConsumerWidget { +class DeviceBottomBar extends ConsumerStatefulWidget { const DeviceBottomBar({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DeviceBottomBarState(); +} + +class _DeviceBottomBarState extends ConsumerState { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ref = this.ref; final devices = ref.watch(connectedDevicesProvider); final selectedId = ref.watch(selectedDeviceProvider); final isDark = Theme.of(context).brightness == Brightness.dark; @@ -63,6 +78,7 @@ class DeviceBottomBar extends ConsumerWidget { // Device chips Expanded( child: ListView.separated( + controller: _scrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 2), itemCount: devices.length, diff --git a/lib/components/layout/sidebar.dart b/lib/components/layout/sidebar.dart index 3a8d5a8..083bcb4 100644 --- a/lib/components/layout/sidebar.dart +++ b/lib/components/layout/sidebar.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/providers/tab_visibility_provider.dart'; import '../../core/theme/color_tokens.dart'; import '../../core/theme/theme_provider.dart'; +import '../../core/utils/smooth_scroll_controller.dart'; import '../../server/providers/server_providers.dart'; class SidebarItem { @@ -82,7 +83,7 @@ final sidebarItems = [ ), ]; -class Sidebar extends ConsumerWidget { +class Sidebar extends ConsumerStatefulWidget { final int selectedIndex; final ValueChanged onItemSelected; @@ -93,7 +94,21 @@ class Sidebar extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _SidebarState(); +} + +class _SidebarState extends ConsumerState { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ref = this.ref; final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final devices = ref.watch(connectedDevicesProvider); @@ -107,6 +122,8 @@ class Sidebar extends ConsumerWidget { ); } + final selectedIndex = widget.selectedIndex; + return Container( width: 68, decoration: BoxDecoration( @@ -158,6 +175,7 @@ class Sidebar extends ConsumerWidget { child: Builder(builder: (context) { final enabledTabs = ref.watch(tabVisibilityProvider); return ListView.builder( + controller: _scrollController, itemCount: sidebarItems.length, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { @@ -170,7 +188,7 @@ class Sidebar extends ConsumerWidget { label: item.label, isSelected: isSelected, isLocked: isLocked, - onTap: () => onItemSelected(index), + onTap: () => widget.onItemSelected(index), ); }, ); diff --git a/lib/components/text/text_component.dart b/lib/components/text/text_component.dart index 32a6a80..85db2b8 100644 --- a/lib/components/text/text_component.dart +++ b/lib/components/text/text_component.dart @@ -154,12 +154,20 @@ class _SelectableEllipsis extends StatelessWidget { painter.dispose(); if (!didOverflow) { + // Text fits within [maxLines] — we pass `maxLines: null` here + // intentionally. Flutter's SelectableText (backed by a multiline + // TextField) reserves vertical height for every line slot up to the + // maxLines limit even when the content is short, which creates + // unwanted whitespace in compact rows (e.g. network header values). + // Since the TextPainter check above already confirmed the text does + // NOT exceed the constraint, clamping is unnecessary and null + // lets the widget collapse to its natural single-line height. return SelectableText(data, style: style, strutStyle: strutStyle, textAlign: textAlign, textDirection: textDirection, - maxLines: maxLines, + maxLines: null, textWidthBasis: textWidthBasis, textHeightBehavior: textHeightBehavior, semanticsLabel: semanticsLabel, diff --git a/lib/components/viewers/json_viewer.dart b/lib/components/viewers/json_viewer.dart index 40f8144..6302004 100644 --- a/lib/components/viewers/json_viewer.dart +++ b/lib/components/viewers/json_viewer.dart @@ -12,8 +12,9 @@ import '../../core/theme/color_tokens.dart'; import '../../core/utils/code_generator.dart'; import '../../core/utils/toast_utils.dart'; import '../../core/utils/code_highlighter.dart'; +import '../../core/utils/smooth_scroll_controller.dart'; -class JsonViewer extends StatelessWidget { +class JsonViewer extends StatefulWidget { final dynamic data; final bool initiallyExpanded; @@ -23,16 +24,31 @@ class JsonViewer extends StatelessWidget { this.initiallyExpanded = true, }); + @override + State createState() => _JsonViewerState(); +} + +class _JsonViewerState extends State { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + String _formatAll() { try { - return const JsonEncoder.withIndent(' ').convert(data); + return const JsonEncoder.withIndent(' ').convert(widget.data); } catch (_) { - return data?.toString() ?? 'null'; + return widget.data?.toString() ?? 'null'; } } @override Widget build(BuildContext context) { + final data = widget.data; + final initiallyExpanded = widget.initiallyExpanded; if (data == null) { return Text('null', style: Theme.of(context).textTheme.labelMedium); } @@ -56,6 +72,7 @@ class JsonViewer extends StatelessWidget { const SizedBox(height: 6), SelectionArea( child: SingleChildScrollView( + controller: _scrollController, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -410,6 +427,13 @@ class _JsonPrettyViewerState extends State { bool? _lastIsDark; /// Per-line TextSpan cache — built lazily per visible line. final Map> _lineSpanCache = {}; + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } @override void initState() { @@ -605,6 +629,7 @@ class _JsonPrettyViewerState extends State { builder: (context, constraints) { final bounded = constraints.maxHeight.isFinite; return ListView.builder( + controller: _scrollController, itemCount: lineCount, shrinkWrap: !bounded, physics: bounded diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart index 42f6860..8968571 100644 --- a/lib/core/theme/theme_provider.dart +++ b/lib/core/theme/theme_provider.dart @@ -124,6 +124,22 @@ final smoothScrollEnabledProvider = (ref) => SmoothScrollEnabledNotifier(), ); +/// How long the smooth scroll animation runs (in milliseconds). +class SmoothScrollDurationNotifier extends StateNotifier { + SmoothScrollDurationNotifier() + : super(AppPreferences().get('smoothScrollDuration', 250) ?? 250); + + void set(int v) { + state = v; + AppPreferences().set('smoothScrollDuration', v); + } +} + +final smoothScrollDurationProvider = + StateNotifierProvider( + (ref) => SmoothScrollDurationNotifier(), +); + // ═══════════════════════════════════════════════════════════════════ // Server start error (transient, not persisted) // ═══════════════════════════════════════════════════════════════════ diff --git a/lib/core/utils/log_message_summary.dart b/lib/core/utils/log_message_summary.dart new file mode 100644 index 0000000..d2c3ce0 --- /dev/null +++ b/lib/core/utils/log_message_summary.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +/// Convert a raw log message into a one-line preview suitable for +/// list-row titles and search-result previews. +/// +/// Special handling for JSON objects/arrays so the user sees something +/// like `Object {3 keys: foo, bar, baz}` instead of just `{` (the first +/// character of the pretty-printed payload that the SDK sent over the +/// wire, which the list-row `maxLines: 1` would otherwise truncate to). +/// +/// Non-JSON messages are returned unchanged. +String summarizeLogMessage(String message) { + final trimmed = message.trimLeft(); + if (trimmed.isEmpty) return message; + if (trimmed[0] != '{' && trimmed[0] != '[') return message; + + // Skip expensive JSON parsing for very large payloads — decoding a + // multi-MB log synchronously on the main thread can cause noticeable + // jank. In practice a JSON preview is not useful for such payloads. + if (trimmed.length > 5000) return message; + + // Try to parse as JSON — RN's `toStr` (and Flutter's `jsonEncode`) ship + // pretty-printed payloads, so we can't rely on a single line. + dynamic parsed; + try { + parsed = jsonDecode(trimmed); + } catch (_) { + return message; // not valid JSON — show the original text + } + + if (parsed is Map) { + final keys = parsed.keys.cast().toList(); + if (keys.isEmpty) return 'Object {}'; + final preview = keys.take(3).join(', '); + final more = keys.length > 3 ? ', …' : ''; + return 'Object {${keys.length} key${keys.length == 1 ? '' : 's'}: $preview$more}'; + } + if (parsed is List) { + if (parsed.isEmpty) return 'Array []'; + return 'Array [${parsed.length}]'; + } + return message; +} diff --git a/lib/core/utils/smooth_scroll_controller.dart b/lib/core/utils/smooth_scroll_controller.dart index cc49cae..896f989 100644 --- a/lib/core/utils/smooth_scroll_controller.dart +++ b/lib/core/utils/smooth_scroll_controller.dart @@ -34,6 +34,7 @@ class SmoothScrollPosition extends ScrollPositionWithSingleContext { // Calculate target pixels starting from current target (if animating) or current position final double basePixels = _targetPixels ?? pixels; final double target = (basePixels + delta * 2.2).clamp(minScrollExtent, maxScrollExtent); + final durationMs = AppPreferences().get('smoothScrollDuration', 250) ?? 250; if (target != pixels) { _targetPixels = target; @@ -47,7 +48,7 @@ class SmoothScrollPosition extends ScrollPositionWithSingleContext { // in `beginActivity` always cleared `_targetPixels` mid-animation. animateTo( target, - duration: const Duration(milliseconds: 250), + duration: Duration(milliseconds: durationMs), curve: Curves.easeOutQuart, ).whenComplete(() { if (_targetPixels == target) { diff --git a/lib/features/all_events/presentation/pages/all_events_page.dart b/lib/features/all_events/presentation/pages/all_events_page.dart index 24b80c9..17db9e9 100644 --- a/lib/features/all_events/presentation/pages/all_events_page.dart +++ b/lib/features/all_events/presentation/pages/all_events_page.dart @@ -3837,11 +3837,24 @@ class _ActionButton extends StatelessWidget { // Log Detail // ═══════════════════════════════════════════════ -class _LogDetail extends StatelessWidget { +class _LogDetail extends StatefulWidget { final LogEntry entry; const _LogDetail({required this.entry}); + @override + State<_LogDetail> createState() => _LogDetailState(); +} + +class _LogDetailState extends State<_LogDetail> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + /// Try to extract JSON from the log message. /// Returns (prefix, parsedJson) or null if no JSON found. static (String, dynamic)? _extractJson(String message) { @@ -3869,10 +3882,12 @@ class _LogDetail extends StatelessWidget { @override Widget build(BuildContext context) { + final entry = widget.entry; final isDark = Theme.of(context).brightness == Brightness.dark; final jsonResult = _extractJson(entry.message); return SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -4244,15 +4259,30 @@ class _TimingBar extends StatelessWidget { } } -class _HeadersView extends StatelessWidget { +class _HeadersView extends StatefulWidget { final NetworkEntry entry; const _HeadersView({required this.entry}); + @override + State<_HeadersView> createState() => _HeadersViewState(); +} + +class _HeadersViewState extends State<_HeadersView> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final entry = widget.entry; final isDark = Theme.of(context).brightness == Brightness.dark; return SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -4589,6 +4619,14 @@ class _BodyView extends ConsumerStatefulWidget { } class _BodyViewState extends ConsumerState<_BodyView> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -4678,6 +4716,7 @@ class _BodyViewState extends ConsumerState<_BodyView> { ), Expanded( child: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: _buildContent( parsedBody: parsedBody, @@ -4815,13 +4854,27 @@ class _InlineJsonViewState extends ConsumerState<_InlineJsonView> { } } -class _TimingView extends StatelessWidget { +class _TimingView extends StatefulWidget { final NetworkEntry entry; const _TimingView({required this.entry}); + @override + State<_TimingView> createState() => _TimingViewState(); +} + +class _TimingViewState extends State<_TimingView> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final entry = widget.entry; final isDark = Theme.of(context).brightness == Brightness.dark; final duration = entry.duration; final startDt = DateTime.fromMillisecondsSinceEpoch(entry.startTime); @@ -4855,6 +4908,7 @@ class _TimingView extends StatelessWidget { } return SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -5233,6 +5287,7 @@ class _StateDetail extends ConsumerStatefulWidget { class _StateDetailState extends ConsumerState<_StateDetail> with SingleTickerProviderStateMixin { late TabController _tabController; + final _diffScrollController = SmoothScrollController(); @override void initState() { @@ -5269,6 +5324,7 @@ class _StateDetailState extends ConsumerState<_StateDetail> void dispose() { _tabController.removeListener(_onTabIndexChange); _tabController.dispose(); + _diffScrollController.dispose(); super.dispose(); } @@ -5322,6 +5378,7 @@ class _StateDetailState extends ConsumerState<_StateDetail> ? EmptyState( icon: LucideIcons.gitCompare, title: 'No diff') : ListView.builder( + controller: _diffScrollController, padding: const EdgeInsets.all(12), itemCount: entry.diff.length, itemBuilder: (context, index) => @@ -5489,6 +5546,13 @@ class _StorageDetail extends StatefulWidget { class _StorageDetailState extends State<_StorageDetail> { bool _formatted = false; + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } StorageEntry get entry => widget.entry; @@ -5527,6 +5591,7 @@ class _StorageDetailState extends State<_StorageDetail> { final canFormat = parsedJson != null && !isAlreadyJson; return SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -5596,16 +5661,31 @@ class _StorageDetailState extends State<_StorageDetail> { // Fallback Detail // ═══════════════════════════════════════════════ -class _FallbackDetail extends StatelessWidget { +class _FallbackDetail extends StatefulWidget { final UnifiedEvent event; const _FallbackDetail({required this.event}); + @override + State<_FallbackDetail> createState() => _FallbackDetailState(); +} + +class _FallbackDetailState extends State<_FallbackDetail> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final event = widget.event; final isDark = Theme.of(context).brightness == Brightness.dark; return SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/all_events/provider/all_events_provider.dart b/lib/features/all_events/provider/all_events_provider.dart index 7c9c54b..7fd414a 100644 --- a/lib/features/all_events/provider/all_events_provider.dart +++ b/lib/features/all_events/provider/all_events_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/providers/tab_visibility_provider.dart'; import '../../../core/utils/duration_format.dart'; +import '../../../core/utils/log_message_summary.dart'; import '../../../models/display/display_entry.dart'; import '../../../models/log/error_event.dart'; import '../../../server/providers/server_providers.dart'; @@ -85,7 +86,10 @@ final allEventsProvider = Provider>((ref) { id: log.id, deviceId: log.deviceId, timestamp: log.timestamp, - title: log.message, + // Summarize JSON object/array messages so the event row shows + // "Object {3 keys: foo, bar}" instead of just "{" (truncated from + // a pretty-printed payload by the row's maxLines: 1). + title: summarizeLogMessage(log.message), subtitle: log.tag ?? 'log', level: log.level.name, rawData: log, diff --git a/lib/features/console/presentation/pages/console_page.dart b/lib/features/console/presentation/pages/console_page.dart index 47fd25a..6746550 100644 --- a/lib/features/console/presentation/pages/console_page.dart +++ b/lib/features/console/presentation/pages/console_page.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import '../../../../l10n/app_localizations.dart'; import 'package:flutter/services.dart'; @@ -6,6 +8,7 @@ import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../components/text/text_component.dart'; +import '../../../../core/utils/log_message_summary.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../components/feedback/empty_state.dart'; @@ -849,7 +852,7 @@ class _LogEntryContent extends StatelessWidget { ), const SizedBox(height: 6), Text( - entry.message, + summarizeLogMessage(entry.message), style: TextStyle( fontFamily: AppConstants.monoFontFamily, fontSize: 12, @@ -914,7 +917,7 @@ class _TagBadge extends StatelessWidget { // Log detail panel // --------------------------------------------------------------------------- -class _LogDetailPanel extends StatelessWidget { +class _LogDetailPanel extends StatefulWidget { final LogEntry entry; final VoidCallback onClose; final VoidCallback onScreenshot; @@ -926,8 +929,22 @@ class _LogDetailPanel extends StatelessWidget { required this.onScreenshot, }); + @override + State<_LogDetailPanel> createState() => _LogDetailPanelState(); +} + +class _LogDetailPanelState extends State<_LogDetailPanel> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final entry = widget.entry; final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final color = _levelColor(entry.level); @@ -997,7 +1014,7 @@ class _LogDetailPanel extends StatelessWidget { Tooltip( message: 'Capture detail as image', child: GestureDetector( - onTap: onScreenshot, + onTap: widget.onScreenshot, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( @@ -1020,7 +1037,7 @@ class _LogDetailPanel extends StatelessWidget { Tooltip( message: S.of(context).closePanel, child: GestureDetector( - onTap: onClose, + onTap: widget.onClose, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( @@ -1044,6 +1061,7 @@ class _LogDetailPanel extends StatelessWidget { // Scrollable content Expanded( child: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1056,36 +1074,11 @@ class _LogDetailPanel extends StatelessWidget { const SizedBox(height: 16), ], - // Message + // Message — with 3 view modes (Tree / JSON / Code), same + // pattern as the All Events detail panel. _SectionLabel(label: S.of(context).message), const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark - ? ColorTokens.darkBackground - : const Color(0xFFF0F0F0), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), - width: 1, - ), - ), - child: TextComponent( - entry.message, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - color: isDark - ? ColorTokens.lightBackground - : ColorTokens.darkNeutral, - height: 1.6, - ), - ), - ), + _LogMessageBlock(message: entry.message, isDark: isDark), // Metadata if (entry.metadata != null && @@ -1171,3 +1164,197 @@ class _SectionLabel extends StatelessWidget { ); } } + + +/// 3-mode view toggle (Tree / JSON / Code) for the log message body — same +/// pattern as the All Events detail panel. +class _LogMessageBlock extends StatefulWidget { + final String message; + final bool isDark; + + const _LogMessageBlock({required this.message, required this.isDark}); + + @override + State<_LogMessageBlock> createState() => _LogMessageBlockState(); +} + +class _LogMessageBlockState extends State<_LogMessageBlock> { + /// 0 = Tree, 1 = JSON, 2 = Code. + int _mode = 0; + + @override + Widget build(BuildContext context) { + final isDark = widget.isDark; + // Try to parse as JSON so Tree/JSON modes can render structured data. + // If the payload isn't valid JSON, both Tree and JSON fall back to + // the raw text — only Code mode has a guaranteed different rendering + // (and even that is identical for non-JSON messages). + dynamic parsed; + try { + parsed = jsonDecode(widget.message); + } catch (_) { + parsed = null; + } + final isJson = parsed is Map || parsed is List; + + Widget body; + switch (_mode) { + case 0: // Tree + body = isJson + ? JsonViewer(data: parsed, initiallyExpanded: true) + : _CodeBlock(text: widget.message, isDark: isDark); + break; + case 1: // JSON + body = isJson + ? _CodeBlock( + text: const JsonEncoder.withIndent(' ').convert(parsed), + isDark: isDark, + ) + : _CodeBlock(text: widget.message, isDark: isDark); + break; + case 2: // Code + default: + body = _CodeBlock(text: widget.message, isDark: isDark); + break; + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: isDark ? ColorTokens.darkBackground : const Color(0xFFF0F0F0), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Mode tabs — same style as the All Events Tree/JSON toggle. + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: _DetailTabBar( + tabs: const ['Tree', 'JSON', 'Code'], + currentIndex: _mode, + onSelect: (i) => setState(() => _mode = i), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: body, + ), + ], + ), + ); + } +} + +/// Plain monospace text block with theme-aware colors. Used by Code mode +/// (and as the fallback for Tree/JSON when the message isn't valid JSON). +class _CodeBlock extends StatelessWidget { + final String text; + final bool isDark; + + const _CodeBlock({required this.text, required this.isDark}); + + @override + Widget build(BuildContext context) { + return TextComponent( + text, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + color: isDark ? ColorTokens.lightBackground : ColorTokens.darkNeutral, + height: 1.6, + ), + ); + } +} + +/// Compact pill-style tab bar for the message view-mode toggle. Same visual +/// pattern as `_DetailTabBar` in `all_events_page.dart` but with a +/// `currentIndex + onSelect` callback instead of a `TabController` — simpler +/// to wire up in a private widget that already manages its own state. +class _DetailTabBar extends StatelessWidget { + final List tabs; + final int currentIndex; + final ValueChanged onSelect; + + const _DetailTabBar({ + required this.tabs, + required this.currentIndex, + required this.onSelect, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = ColorTokens.primary; + return Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < tabs.length; i++) + _buildSegment(tabs[i], i, isDark, accent), + ], + ), + ); + } + + Widget _buildSegment(String label, int index, bool isDark, Color accent) { + final selected = index == currentIndex; + return GestureDetector( + onTap: () => onSelect(index), + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: selected + ? (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.white) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: selected + ? Border.all( + color: isDark + ? accent.withValues(alpha: 0.25) + : Colors.black.withValues(alpha: 0.06), + ) + : Border.all(color: Colors.transparent), + boxShadow: selected && !isDark + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 2, + offset: Offset(0, 1), + ), + ] + : null, + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + letterSpacing: 0.2, + color: selected + ? accent + : (isDark ? Colors.grey[500] : Colors.grey[600]), + ), + ), + ), + ); + } +} diff --git a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart index b26d3e8..8816ce5 100644 --- a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart +++ b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart @@ -987,6 +987,9 @@ class _ErrorDetailPanel extends ConsumerStatefulWidget { class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> with SingleTickerProviderStateMixin { late TabController _tabController; + final _messageScrollController = SmoothScrollController(); + final _stackTraceScrollController = SmoothScrollController(); + final _detailsScrollController = SmoothScrollController(); @override void initState() { @@ -1001,6 +1004,9 @@ class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> @override void dispose() { _tabController.dispose(); + _messageScrollController.dispose(); + _stackTraceScrollController.dispose(); + _detailsScrollController.dispose(); super.dispose(); } @@ -1192,6 +1198,7 @@ class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> children: [ // Message tab SingleChildScrollView( + controller: _messageScrollController, padding: const EdgeInsets.all(16), child: TextComponent( entry.message, @@ -1205,6 +1212,7 @@ class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> // Stack trace tab entry.stackTrace != null ? SingleChildScrollView( + controller: _stackTraceScrollController, padding: const EdgeInsets.all(16), child: TextComponent( entry.stackTrace!, @@ -1220,6 +1228,7 @@ class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> ), // Details tab SingleChildScrollView( + controller: _detailsScrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/network_inspector/presentation/pages/network_inspector_page.dart b/lib/features/network_inspector/presentation/pages/network_inspector_page.dart index 15df4e9..0b4523e 100644 --- a/lib/features/network_inspector/presentation/pages/network_inspector_page.dart +++ b/lib/features/network_inspector/presentation/pages/network_inspector_page.dart @@ -1239,7 +1239,7 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> controller: _tabController, isDark: isDark, accentColor: ColorTokens.primary, - tabs: [S.of(context).headers, S.of(context).request, S.of(context).response, S.of(context).timing], + tabs: const ['Headers', 'Request', 'Response', 'Timing'], ), ], ), @@ -1255,12 +1255,12 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> _HeadersTab(entry: entry), _BodyTab( body: entry.requestBody, - label: S.of(context).requestBody, + label: 'Request', deviceId: entry.deviceId, ), _BodyTab( body: entry.responseBody, - label: S.of(context).responseBody, + label: 'Response', deviceId: entry.deviceId, ), _TimingTab(entry: entry), @@ -1672,17 +1672,17 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> ), const Divider(height: 1), // Headers section - _screenshotSection(S.of(context).requestHeaders, isDark), + _screenshotSection('Request Headers', isDark), ...entry.requestHeaders.entries.map((e) => _screenshotHeaderRow(e.key, e.value, isDark)), if (entry.responseHeaders.isNotEmpty) ...[ - _screenshotSection(S.of(context).responseHeaders, isDark), + _screenshotSection('Response Headers', isDark), ...entry.responseHeaders.entries.map((e) => _screenshotHeaderRow(e.key, e.value, isDark)), ], // Request body if (parsedReqBody != null) ...[ - _screenshotSection(S.of(context).requestBody, isDark), + _screenshotSection('Request Body', isDark), Padding( padding: const EdgeInsets.all(12), child: parsedReqBody is Map || parsedReqBody is List @@ -1692,7 +1692,7 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> ], // Response body if (parsedResBody != null) ...[ - _screenshotSection(S.of(context).responseBody, isDark), + _screenshotSection('Response Body', isDark), Padding( padding: const EdgeInsets.all(12), child: parsedResBody is Map || parsedResBody is List @@ -1707,7 +1707,7 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> Widget _buildTabScreenshotWidget(bool isDark, int tabIndex) { final entry = widget.entry; - final tabNames = [S.of(context).headers, 'Request', 'Response', 'Timing']; + final tabNames = const ['Headers', 'Request', 'Response', 'Timing']; Widget tabContent; switch (tabIndex) { @@ -1716,11 +1716,11 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _screenshotSection(S.of(context).requestHeaders, isDark), + _screenshotSection('Request Headers', isDark), ...entry.requestHeaders.entries.map((e) => _screenshotHeaderRow(e.key, e.value, isDark)), if (entry.responseHeaders.isNotEmpty) ...[ - _screenshotSection(S.of(context).responseHeaders, isDark), + _screenshotSection('Response Headers', isDark), ...entry.responseHeaders.entries.map((e) => _screenshotHeaderRow(e.key, e.value, isDark)), ], @@ -2089,15 +2089,30 @@ class _TimingBar extends StatelessWidget { // Headers tab // --------------------------------------------------------------------------- -class _HeadersTab extends StatelessWidget { +class _HeadersTab extends StatefulWidget { final NetworkEntry entry; const _HeadersTab({required this.entry}); + @override + State<_HeadersTab> createState() => _HeadersTabState(); +} + +class _HeadersTabState extends State<_HeadersTab> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final entry = widget.entry; final isDark = Theme.of(context).brightness == Brightness.dark; return SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2105,7 +2120,7 @@ class _HeadersTab extends StatelessWidget { _HeaderSection( icon: LucideIcons.arrowUpRight, iconColor: ColorTokens.primary, - title: S.of(context).requestHeaders, + title: 'Request Headers', count: entry.requestHeaders.length, headers: entry.requestHeaders, isDark: isDark, @@ -2114,7 +2129,7 @@ class _HeadersTab extends StatelessWidget { _HeaderSection( icon: LucideIcons.arrowDownLeft, iconColor: ColorTokens.success, - title: S.of(context).responseHeaders, + title: 'Response Headers', count: entry.responseHeaders.length, headers: entry.responseHeaders, isDark: isDark, @@ -2281,7 +2296,7 @@ class _HeaderRowWithCopyState extends State<_HeaderRowWithCopy> { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 180, + width: 170, child: TextComponent( widget.headerKey, style: TextStyle( @@ -2375,6 +2390,14 @@ class _BodyTab extends ConsumerStatefulWidget { } class _BodyTabState extends ConsumerState<_BodyTab> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -2481,6 +2504,7 @@ class _BodyTabState extends ConsumerState<_BodyTab> { // Body content Expanded( child: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: _buildContent( parsedBody: parsedBody, @@ -2524,13 +2548,27 @@ class _BodyTabState extends ConsumerState<_BodyTab> { // Timing tab // --------------------------------------------------------------------------- -class _TimingTab extends StatelessWidget { +class _TimingTab extends StatefulWidget { final NetworkEntry entry; const _TimingTab({required this.entry}); + @override + State<_TimingTab> createState() => _TimingTabState(); +} + +class _TimingTabState extends State<_TimingTab> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final entry = widget.entry; final isDark = Theme.of(context).brightness == Brightness.dark; final duration = entry.duration; final startDt = DateTime.fromMillisecondsSinceEpoch(entry.startTime); @@ -2564,6 +2602,7 @@ class _TimingTab extends StatelessWidget { } return SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 7ff0160..7bb4bf8 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -916,6 +916,61 @@ class _AppearanceSection extends ConsumerWidget { style: TextStyle(fontSize: 10, color: Colors.grey[600], height: 1.4), ), ), + if (ref.watch(smoothScrollEnabledProvider)) ...[ + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 100, + child: Text( + S.of(context).smoothScrollingDuration, + style: TextStyle(fontSize: 13, color: Colors.grey[500]), + ), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), + ), + child: Slider( + value: ref.watch(smoothScrollDurationProvider).toDouble(), + min: 100, + max: 1000, + divisions: 18, + label: '${ref.watch(smoothScrollDurationProvider)}ms', + activeColor: ColorTokens.primary, + inactiveColor: isDark ? Colors.white12 : Colors.black12, + onChanged: (v) => ref + .read(smoothScrollDurationProvider.notifier) + .set(v.round()), + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 50, + child: Text( + '${ref.watch(smoothScrollDurationProvider)}ms', + style: TextStyle( + fontSize: 12, + color: ColorTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 100), + child: Text( + S.of(context).smoothScrollingDurationDesc, + style: TextStyle(fontSize: 10, color: Colors.grey[600], height: 1.4), + ), + ), + ], ], ); } diff --git a/lib/features/state_inspector/presentation/pages/state_inspector_page.dart b/lib/features/state_inspector/presentation/pages/state_inspector_page.dart index 9ebb685..a8db35b 100644 --- a/lib/features/state_inspector/presentation/pages/state_inspector_page.dart +++ b/lib/features/state_inspector/presentation/pages/state_inspector_page.dart @@ -745,6 +745,13 @@ class _StateJsonToggleView extends StatefulWidget { class _StateJsonToggleViewState extends State<_StateJsonToggleView> { bool _jsonEverOpened = false; + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } @override void didUpdateWidget(_StateJsonToggleView oldWidget) { @@ -764,6 +771,7 @@ class _StateJsonToggleViewState extends State<_StateJsonToggleView> { Offstage( offstage: widget.jsonMode, child: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: JsonViewer(data: widget.data, initiallyExpanded: true), ), @@ -781,13 +789,27 @@ class _StateJsonToggleViewState extends State<_StateJsonToggleView> { } } -class _DiffView extends StatelessWidget { +class _DiffView extends StatefulWidget { final List diff; const _DiffView({required this.diff}); + @override + State<_DiffView> createState() => _DiffViewState(); +} + +class _DiffViewState extends State<_DiffView> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final diff = widget.diff; if (diff.isEmpty) { return EmptyState( icon: LucideIcons.equal, @@ -796,6 +818,7 @@ class _DiffView extends StatelessWidget { } return ListView.builder( + controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: diff.length, itemBuilder: (context, index) { diff --git a/lib/features/storage_viewer/presentation/pages/storage_viewer_page.dart b/lib/features/storage_viewer/presentation/pages/storage_viewer_page.dart index ae999e5..0a9849b 100644 --- a/lib/features/storage_viewer/presentation/pages/storage_viewer_page.dart +++ b/lib/features/storage_viewer/presentation/pages/storage_viewer_page.dart @@ -808,6 +808,13 @@ class _StorageDetailPanelState extends State<_StorageDetailPanel> { bool _formatted = false; bool _jsonMode = false; bool _jsonEverOpened = false; + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } StorageEntry get entry => widget.entry; @@ -993,6 +1000,7 @@ class _StorageDetailPanelState extends State<_StorageDetailPanel> { // Content Expanded( child: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 211f675..e852c11 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -295,5 +295,7 @@ "growingCollection": "Growing Collection", "custom": "Custom", "smoothScrolling": "Smooth scroll", - "smoothScrollingDesc": "Smooths mouse-wheel scroll events. Disable this option if you notice any lag or performance drop." + "smoothScrollingDesc": "Smooths mouse-wheel scroll events. Disable this option if you notice any lag or performance drop.", + "smoothScrollingDuration": "Scroll duration", + "smoothScrollingDurationDesc": "The duration of the smooth scroll animation in milliseconds." } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d9a619e..09295fd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -295,5 +295,7 @@ "growingCollection": "Collection croissante", "custom": "Personnalisé", "smoothScrolling": "Défilement fluide", - "smoothScrollingDesc": "Rend le défilement de la molette de la souris plus fluide. Désactivez cette option si vous constatez des ralentissements ou une baisse de performance." + "smoothScrollingDesc": "Rend le défilement de la molette de la souris plus fluide. Désactivez cette option si vous constatez des ralentissements ou une baisse de performance.", + "smoothScrollingDuration": "Durée du défilement", + "smoothScrollingDurationDesc": "La durée de l'animation de défilement en millisecondes." } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 66afd47..8717a63 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -295,5 +295,7 @@ "growingCollection": "増加中のコレクション", "custom": "カスタム", "smoothScrolling": "スムーズスクロール", - "smoothScrollingDesc": "マウスホイールのスクロールイベントに滑らかなアニメーションを追加します。ラグやパフォーマンス低下を感じる場合は、この設定をオフにしてください。" + "smoothScrollingDesc": "マウスホイールのスクロールイベントに滑らかなアニメーションを追加します。ラグやパフォーマンス低下を感じる場合は、この設定をオフにしてください。", + "smoothScrollingDuration": "スクロール時間", + "smoothScrollingDurationDesc": "スクロールアニメーションの時間(ミリ秒)。" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0ce7a3d..38c1e0f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1880,6 +1880,18 @@ abstract class S { /// In en, this message translates to: /// **'Smooths mouse-wheel scroll events. Disable this option if you notice any lag or performance drop.'** String get smoothScrollingDesc; + + /// No description provided for @smoothScrollingDuration. + /// + /// In en, this message translates to: + /// **'Scroll duration'** + String get smoothScrollingDuration; + + /// No description provided for @smoothScrollingDurationDesc. + /// + /// In en, this message translates to: + /// **'The duration of the smooth scroll animation in milliseconds.'** + String get smoothScrollingDurationDesc; } class _SDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ce1862e..b209fa5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1020,4 +1020,11 @@ class SEn extends S { @override String get smoothScrollingDesc => 'Smooths mouse-wheel scroll events. Disable this option if you notice any lag or performance drop.'; + + @override + String get smoothScrollingDuration => 'Scroll duration'; + + @override + String get smoothScrollingDurationDesc => + 'The duration of the smooth scroll animation in milliseconds.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 7db16a8..ca3272f 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1022,4 +1022,11 @@ class SFr extends S { @override String get smoothScrollingDesc => 'Rend le défilement de la molette de la souris plus fluide. Désactivez cette option si vous constatez des ralentissements ou une baisse de performance.'; + + @override + String get smoothScrollingDuration => 'Durée du défilement'; + + @override + String get smoothScrollingDurationDesc => + 'La durée de l\'animation de défilement en millisecondes.'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index a78bfca..22d849f 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1011,4 +1011,10 @@ class SJa extends S { @override String get smoothScrollingDesc => 'マウスホイールのスクロールイベントに滑らかなアニメーションを追加します。ラグやパフォーマンス低下を感じる場合は、この設定をオフにしてください。'; + + @override + String get smoothScrollingDuration => 'スクロール時間'; + + @override + String get smoothScrollingDurationDesc => 'スクロールアニメーションの時間(ミリ秒)。'; } diff --git a/lib/l10n/app_localizations_vi.dart b/lib/l10n/app_localizations_vi.dart index 4373380..9cf616b 100644 --- a/lib/l10n/app_localizations_vi.dart +++ b/lib/l10n/app_localizations_vi.dart @@ -1023,4 +1023,11 @@ class SVi extends S { @override String get smoothScrollingDesc => 'Tạo hiệu ứng cuộn mượt mà hơn khi sử dụng con lăn chuột. Nếu bạn thấy lag hoặc ảnh hưởng đến hiệu năng, hãy tắt cài đặt này.'; + + @override + String get smoothScrollingDuration => 'Thời gian cuộn'; + + @override + String get smoothScrollingDurationDesc => + 'Thời gian chạy hiệu ứng cuộn mượt mà tính bằng mili-giây.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0fcc79b..5492820 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1002,6 +1002,12 @@ class SZh extends S { @override String get smoothScrollingDesc => '为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。'; + + @override + String get smoothScrollingDuration => '滚动持续时间'; + + @override + String get smoothScrollingDurationDesc => '滚动动画的持续时间(毫秒)。'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -2002,6 +2008,12 @@ class SZhCn extends SZh { @override String get smoothScrollingDesc => '为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。'; + + @override + String get smoothScrollingDuration => '滚动持续时间'; + + @override + String get smoothScrollingDurationDesc => '滚动动画的持续时间(毫秒)。'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -3002,4 +3014,10 @@ class SZhTw extends SZh { @override String get smoothScrollingDesc => '為滑鼠滾輪事件添加平滑動畫。如果遇到卡頓或性能下降,請關閉此選項。'; + + @override + String get smoothScrollingDuration => '滾動持續時間'; + + @override + String get smoothScrollingDurationDesc => '滾動動畫的持續時間(毫秒)。'; } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 2204c2f..efc8ec6 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -295,5 +295,7 @@ "growingCollection": "Bộ sưu tập tăng trưởng", "custom": "Tùy chỉnh", "smoothScrolling": "Cuộn mượt", - "smoothScrollingDesc": "Tạo hiệu ứng cuộn mượt mà hơn khi sử dụng con lăn chuột. Nếu bạn thấy lag hoặc ảnh hưởng đến hiệu năng, hãy tắt cài đặt này." + "smoothScrollingDesc": "Tạo hiệu ứng cuộn mượt mà hơn khi sử dụng con lăn chuột. Nếu bạn thấy lag hoặc ảnh hưởng đến hiệu năng, hãy tắt cài đặt này.", + "smoothScrollingDuration": "Thời gian cuộn", + "smoothScrollingDurationDesc": "Thời gian chạy hiệu ứng cuộn mượt mà tính bằng mili-giây." } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 41478ac..1a4aee3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -295,5 +295,7 @@ "growingCollection": "增长中的集合", "custom": "自定义", "smoothScrolling": "平滑滚动", - "smoothScrollingDesc": "为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。" + "smoothScrollingDesc": "为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。", + "smoothScrollingDuration": "滚动持续时间", + "smoothScrollingDurationDesc": "滚动动画的持续时间(毫秒)。" } \ No newline at end of file diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 852669c..2527cca 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -295,5 +295,7 @@ "growingCollection": "增长中的集合", "custom": "自定义", "smoothScrolling": "平滑滚动", - "smoothScrollingDesc": "为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。" + "smoothScrollingDesc": "为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。", + "smoothScrollingDuration": "滚动持续时间", + "smoothScrollingDurationDesc": "滚动动画的持续时间(毫秒)。" } \ No newline at end of file diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 5795e24..d6946ba 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -295,5 +295,7 @@ "growingCollection": "增長中的集合", "custom": "自訂", "smoothScrolling": "平滑捲動", - "smoothScrollingDesc": "為滑鼠滾輪事件添加平滑動畫。如果遇到卡頓或性能下降,請關閉此選項。" + "smoothScrollingDesc": "為滑鼠滾輪事件添加平滑動畫。如果遇到卡頓或性能下降,請關閉此選項。", + "smoothScrollingDuration": "滾動持續時間", + "smoothScrollingDurationDesc": "滾動動畫的持續時間(毫秒)。" } \ No newline at end of file