diff --git a/client_sdks/devconnect-react-native/src/client.ts b/client_sdks/devconnect-react-native/src/client.ts index 690b813..296dfea 100644 --- a/client_sdks/devconnect-react-native/src/client.ts +++ b/client_sdks/devconnect-react-native/src/client.ts @@ -380,6 +380,83 @@ function classifyUrl(url: string): string { return 'app'; } +function wrapFetchInit( + init: RequestInit | undefined, + tracker: { headers?: any; body?: any }, +): RequestInit | undefined { + if (!init) return init; + + const headers = init.headers; + if (headers && typeof headers === 'object' && !(headers instanceof Headers)) { + const proxy = new Proxy(headers as Record, { + set(target, key, value) { + (target as any)[key] = value; + tracker.headers = { ...target }; + return true; + }, + deleteProperty(target, key) { + delete (target as any)[key]; + tracker.headers = { ...target }; + return true; + }, + }); + (init as any).headers = proxy; + tracker.headers = { ...(headers as Record) }; + } else if (headers instanceof Headers) { + const origAppend = headers.append.bind(headers); + const origSet = headers.set.bind(headers); + const origDelete = headers.delete.bind(headers); + headers.append = function (name: string, value: string) { + const r = origAppend(name, value); + const snap: Record = {}; + headers.forEach((v, k) => (snap[k.toLowerCase()] = v)); + tracker.headers = snap; + return r; + }; + headers.set = function (name: string, value: string) { + const r = origSet(name, value); + const snap: Record = {}; + headers.forEach((v, k) => (snap[k.toLowerCase()] = v)); + tracker.headers = snap; + return r; + }; + headers.delete = function (name: string) { + const r = origDelete(name); + const snap: Record = {}; + headers.forEach((v, k) => (snap[k.toLowerCase()] = v)); + tracker.headers = snap; + return r; + }; + const snap: Record = {}; + headers.forEach((v, k) => (snap[k.toLowerCase()] = v)); + tracker.headers = snap; + } else { + tracker.headers = headers; + } + + if ('body' in init) tracker.body = init.body; + (init as any)['__dcBodyGet'] = () => tracker.body; + + return init; +} + +function readFinalHeaders( + tracker: { headers?: any }, + init: RequestInit | undefined, +): Record { + const out: Record = {}; + const src = tracker.headers ?? init?.headers; + if (!src) return out; + if (typeof Headers !== 'undefined' && src instanceof Headers) { + src.forEach((v, k) => (out[k.toLowerCase()] = v)); + } else if (Array.isArray(src)) { + for (const [k, v] of src) out[String(k).toLowerCase()] = String(v); + } else if (typeof src === 'object') { + for (const [k, v] of Object.entries(src)) out[k.toLowerCase()] = String(v); + } + return out; +} + // ---- Main Class ---- export class DevConnect { @@ -752,24 +829,60 @@ export class DevConnect { global.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise { const requestId = generateId(); const startTime = Date.now(); - const method = init?.method?.toUpperCase() ?? 'GET'; - const url = typeof input === 'string' ? input : input.toString(); - const reqHeaders: Record = {}; - if (init?.headers) { - if (init.headers instanceof Headers) { - init.headers.forEach((v, k) => (reqHeaders[k] = v)); - } else if (typeof init.headers === 'object') { - Object.entries(init.headers).forEach(([k, v]) => (reqHeaders[k] = String(v))); + let method: string; + if (init?.method) { + method = init.method.toUpperCase(); + } else if (typeof Request !== 'undefined' && input instanceof Request) { + method = (input as Request).method.toUpperCase(); + } else { + method = 'GET'; + } + + let url: string; + if (typeof input === 'string') { + url = input; + } else if (input instanceof URL) { + url = input.toString(); + } else if (typeof Request !== 'undefined' && input instanceof Request) { + url = input.url; + } else { + url = (input as any)?.url ?? String(input); + } + + // AWS SDK v3 (@aws-sdk/fetch-http-handler) calls `fetch(request)` where + // `request` is a fully-built Request object — init is undefined in that + // case. All headers (including X-Amz-Date, Authorization added by the + // signer middleware) are inside request.headers by the time fetch is + // invoked. We must read from request, not init. + const isRequestInput = + typeof Request !== 'undefined' && input instanceof Request; + let reqHeaders: Record; + let finalBody: any; + + if (isRequestInput) { + const r = input as Request; + reqHeaders = {}; + r.headers.forEach((v, k) => (reqHeaders[k.toLowerCase()] = v)); + try { + finalBody = await r.clone().text(); + } catch (_) { + finalBody = undefined; } + } else { + const tracker: { headers?: any; body?: any } = {}; + const trackedInit = wrapFetchInit(init, tracker); + (init as any) = trackedInit; + reqHeaders = readFinalHeaders(tracker, trackedInit); + finalBody = tracker.body !== undefined ? tracker.body : trackedInit?.body; } let requestBody: any; - if (init?.body) { - if (init.body instanceof FormData) { + if (finalBody) { + if (finalBody instanceof FormData) { const fields: Record = {}; const files: any[] = []; - for (const [key, value] of (init.body as any).entries()) { + for (const [key, value] of (finalBody as any).entries()) { if (value instanceof Blob || (value && typeof value === 'object' && value.uri)) { files.push({ key, filename: value.name ?? value.filename ?? 'unknown', type: value.type ?? value.contentType ?? 'unknown', size: value.size ?? value.length }); } else { @@ -778,7 +891,7 @@ export class DevConnect { } requestBody = { ...fields, ...(files.length ? { _files: files, _contentType: 'multipart/form-data' } : {}) }; } else { - try { requestBody = JSON.parse(init.body as string); } catch (_) { requestBody = String(init.body); } + try { requestBody = JSON.parse(finalBody as string); } catch (_) { requestBody = String(finalBody); } } } @@ -825,7 +938,12 @@ export class DevConnect { let requestBody: any; const origOpen = xhr.open.bind(xhr); - xhr.open = (m: string, u: string, ...args: any[]) => { method = m.toUpperCase(); url = u; return origOpen(m, u, ...args); }; + xhr.open = (m: string, u: string | URL, ...args: any[]) => { + method = m.toUpperCase(); + // Coerce URL/Request to a string in case a polyfill accepts them + url = typeof u === 'string' ? u : (u as any)?.url ?? String(u); + return origOpen(m, u as string, ...args); + }; const origSetHeader = xhr.setRequestHeader.bind(xhr); xhr.setRequestHeader = (n: string, v: string) => { reqHeaders[n] = v; return origSetHeader(n, v); }; diff --git a/lib/app.dart b/lib/app.dart index ae28bab..3b79a49 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'components/viewers/json_viewer.dart'; import 'core/providers/locale_provider.dart'; import 'core/routes/app_router.dart'; import 'core/theme/app_theme.dart'; @@ -17,15 +18,27 @@ class DevConnectApp extends ConsumerStatefulWidget { } class _DevConnectAppState extends ConsumerState { + /// Device IDs we've already seen, so a freshly-connected device (vs an + /// existing one re-emitting) is the trigger for cache invalidation. + Set? _knownDeviceIds; + @override void initState() { super.initState(); + // Auto-clear the JSON highlight cache after long background sessions. + HighlightCacheLifecycleObserver.instance.attach(); // Auto-start WebSocket server on app launch WidgetsBinding.instance.addPostFrameCallback((_) { _autoStartServer(); }); } + @override + void dispose() { + HighlightCacheLifecycleObserver.instance.detach(); + super.dispose(); + } + Future _autoStartServer() async { final server = ref.read(wsServerProvider); if (!server.isRunning) { @@ -63,6 +76,23 @@ class _DevConnectAppState extends ConsumerState { // events are recorded even when no Settings page is open. ref.watch(deviceHistoryMirrorProvider); + // A. Invalidate the JSON highlight cache when the user picks a + // different device — data is filtered per-device, so old highlights + // belong to a different payload. + ref.listen(selectedDeviceProvider, (_, next) { + HighlightCacheLifecycleObserver.instance.clearCache(); + }); + + // B. Invalidate the JSON highlight cache when a NEW device connects. + // A reconnect of an already-known device (e.g. hot reload) does NOT + // trigger this — only an addition to the device list. + final devices = ref.watch(connectedDevicesProvider); + final ids = devices.map((d) => d.deviceId).toSet(); + if (_knownDeviceIds != null && ids.any((id) => !_knownDeviceIds!.contains(id))) { + HighlightCacheLifecycleObserver.instance.clearCache(); + } + _knownDeviceIds = ids; + final locale = ref.watch(localeProvider); return MaterialApp.router( diff --git a/lib/components/feedback/lib_update_tips.dart b/lib/components/feedback/lib_update_tips.dart new file mode 100644 index 0000000..2ca0bc5 --- /dev/null +++ b/lib/components/feedback/lib_update_tips.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../l10n/app_localizations.dart'; + +/// Compact, dismissible "Tips" pill anchored at the top-right of the +/// title bar. Collapsed to a small amber dot + label, expands on +/// hover into a glass panel listing the SDKs the client app must be +/// on for DevConnect to ingest the full payload. +/// +/// Design philosophy: +/// - **Rest state** is one dot + one short word. It must NEVER +/// compete with page content or block clicks. +/// - **Hover state** springs open with a soft cubic ease + tinted +/// glass refraction — feels deliberate, not a popover. +/// - **Self-dismiss**: panel collapses back to the dot when the +/// mouse leaves; we never block the screen. +/// +/// Versions mirror what `client_sdks//...` currently ships. +/// When you bump a version there, bump the same number in +/// `_SdkCatalog` below. +class LibUpdateTips extends StatefulWidget { + const LibUpdateTips({super.key}); + + @override + State createState() => _LibUpdateTipsState(); +} + +class _LibUpdateTipsState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final loc = S.of(context); + final accent = const Color(0xFFFBBF24); // amber — advisory, never error + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + height: _hovered ? null : 28, + width: _hovered ? 340 : 80, + padding: EdgeInsets.symmetric( + horizontal: _hovered ? 14 : 11, + vertical: _hovered ? 12 : 6, + ), + decoration: BoxDecoration( + color: _hovered + ? (isDark + ? const Color(0xFF1F242B).withValues(alpha: 0.96) + : Colors.white.withValues(alpha: 0.97)) + : (isDark + ? const Color(0xFF1F242B).withValues(alpha: 0.85) + : Colors.white.withValues(alpha: 0.92)), + borderRadius: BorderRadius.circular(_hovered ? 14 : 14), + // 1px border even at rest so the pill reads as a discrete + // chip, not a smudge floating in the title bar. + border: Border.all( + color: _hovered + ? (isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.08)) + : (isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.05)), + ), + boxShadow: [ + if (_hovered) + BoxShadow( + color: accent.withValues(alpha: isDark ? 0.18 : 0.12), + blurRadius: 18, + spreadRadius: -4, + offset: const Offset(0, 6), + ) + else + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: _hovered + ? _ExpandedPanel( + accent: accent, + isDark: isDark, + loc: loc, + sdkList: _SdkCatalog.entries, + ) + : _CollapsedPill(accent: accent, isDark: isDark, loc: loc), + ), + ); + } +} + +// ─── Collapsed pill (rest state) ───────────────────────────────────── + +class _CollapsedPill extends StatelessWidget { + final Color accent; + final bool isDark; + final S loc; + + const _CollapsedPill({ + required this.accent, + required this.isDark, + required this.loc, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.sparkles, + size: 12, + color: accent, + ), + const SizedBox(width: 6), + Text( + loc.sdkTipsPill, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + color: isDark ? Colors.white : Colors.black87, + ), + ), + ], + ); + } +} + +// ─── Expanded panel (hover state) ──────────────────────────────────── + +class _ExpandedPanel extends StatelessWidget { + final Color accent; + final bool isDark; + final S loc; + final List<_SdkEntry> sdkList; + + const _ExpandedPanel({ + required this.accent, + required this.isDark, + required this.loc, + required this.sdkList, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(LucideIcons.sparkles, size: 13, color: accent), + const SizedBox(width: 6), + Text( + loc.sdkTipsHeader, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + letterSpacing: 0.2, + ), + ), + ], + ), + const SizedBox(height: 6), + // Subtitle now does the heavy lifting. It explains *why* + // updating the libraries matters, instead of just pointing at + // a folder. + Text( + loc.sdkTipsSubtitle, + style: TextStyle( + fontSize: 9.5, + color: isDark ? Colors.white54 : Colors.black54, + height: 1.4, + ), + ), + const SizedBox(height: 10), + Container( + height: 1, + margin: const EdgeInsets.only(bottom: 6), + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final sdk in sdkList) ...[ + _SdkRow(entry: sdk, isDark: isDark, loc: loc), + const SizedBox(height: 6), + ], + ], + ), + ], + ); + } +} + +// ─── SDK entry data ────────────────────────────────────────────────── + +class _SdkEntry { + final _SdkPlatform platform; + final String name; + final String version; + final String note; + + const _SdkEntry({ + required this.platform, + required this.name, + required this.version, + required this.note, + }); +} + +enum _SdkPlatform { flutter, reactNative, android } + +class _SdkCatalog { + static const List<_SdkEntry> entries = [ + _SdkEntry( + platform: _SdkPlatform.flutter, + name: 'devconnect_manage_kit', + version: '1.0.4', + note: 'pubspec.yaml → devconnect_manage_kit: ^1.0.4', + ), + _SdkEntry( + platform: _SdkPlatform.reactNative, + name: 'devconnect-manage-kit', + version: '1.0.5', + note: 'npm i / yarn add / pnpm add devconnect-manage-kit@1.0.5', + ), + _SdkEntry( + platform: _SdkPlatform.android, + name: 'com.devconnect', + version: '1.0.0', + note: 'implementation("com.devconnect:devconnect:1.0.0")', + ), + ]; +} + +// ─── SDK row ────────────────────────────────────────────────────────── + +class _SdkRow extends StatefulWidget { + final _SdkEntry entry; + final bool isDark; + final S loc; + + const _SdkRow({ + required this.entry, + required this.isDark, + required this.loc, + }); + + @override + State<_SdkRow> createState() => _SdkRowState(); +} + +class _SdkRowState extends State<_SdkRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final e = widget.entry; + final isDark = widget.isDark; + final loc = widget.loc; + const accent = Color(0xFFFBBF24); + + String platformLabel(_SdkPlatform p) { + switch (p) { + case _SdkPlatform.flutter: + return loc.sdkTipsFlutter; + case _SdkPlatform.reactNative: + return loc.sdkTipsReactNative; + case _SdkPlatform.android: + return loc.sdkTipsAndroid; + } + } + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + color: _hovered + ? (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04)) + : Colors.transparent, + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: accent.withValues(alpha: isDark ? 0.16 : 0.14), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + platformLabel(e.platform), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + color: accent, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + e.name, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: isDark ? Colors.white : Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + loc.sdkTipsVersionLabel(e.version), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 2), + child: Text( + e.note, + style: TextStyle( + fontSize: 9.5, + fontFamily: 'monospace', + color: isDark ? Colors.white38 : Colors.black45, + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/misc/service_tag.dart b/lib/components/misc/service_tag.dart new file mode 100644 index 0000000..157985f --- /dev/null +++ b/lib/components/misc/service_tag.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/color_tokens.dart'; + +class ServiceTag extends StatelessWidget { + final String name; + const ServiceTag({super.key, required this.name}); + + @override + Widget build(BuildContext context) { + final color = colorForService(name); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + name, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w700, + color: color, + letterSpacing: 0.3, + ), + ), + ); + } + + static Color colorForService(String name) { + switch (name) { + case 'AWS': + case 'AWS Cognito': + return const Color(0xFFFF9900); + case 'Google Maps': + return const Color(0xFF4285F4); + case 'Firebase': + return const Color(0xFFFFCA28); + case 'Stripe': + return const Color(0xFF635BFF); + case 'GitHub': + return const Color(0xFF8B949E); + case 'Sentry': + return const Color(0xFF6C5FC7); + default: + return ColorTokens.primary; + } + } +} diff --git a/lib/components/viewers/json_viewer.dart b/lib/components/viewers/json_viewer.dart index 6302004..d111d40 100644 --- a/lib/components/viewers/json_viewer.dart +++ b/lib/components/viewers/json_viewer.dart @@ -9,9 +9,11 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/constants/app_constants.dart'; import '../../core/theme/color_tokens.dart'; +import '../../core/theme/theme_provider.dart'; import '../../core/utils/code_generator.dart'; import '../../core/utils/toast_utils.dart'; import '../../core/utils/code_highlighter.dart'; +import '../../core/utils/lru_cache.dart'; import '../../core/utils/smooth_scroll_controller.dart'; class JsonViewer extends StatefulWidget { @@ -70,19 +72,21 @@ class _JsonViewerState extends State { }, ), const SizedBox(height: 6), - SelectionArea( - child: SingleChildScrollView( - controller: _scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _JsonNode( - keyName: null, - value: data, - depth: 0, - initiallyExpanded: initiallyExpanded, - ), - ], + Flexible( + child: SelectionArea( + child: SingleChildScrollView( + controller: _scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _JsonNode( + keyName: null, + value: data, + depth: 0, + initiallyExpanded: initiallyExpanded, + ), + ], + ), ), ), ), @@ -345,12 +349,25 @@ class _HighlightResult { /// Runs in isolate — per-line tokenization. _HighlightResult _computeHighlight(List args) { - final data = args[0]; + final rawData = args[0]; final isDark = args[1] as bool; + dynamic data = rawData; + if (rawData is String) { + try { + data = jsonDecode(rawData); + } catch (_) { + data = rawData; + } + } + String formatted; try { - formatted = const JsonEncoder.withIndent(' ').convert(data); + if (data is Map || data is List) { + formatted = const JsonEncoder.withIndent(' ').convert(data); + } else { + formatted = data?.toString() ?? 'null'; + } } catch (e) { formatted = data?.toString() ?? 'null'; } @@ -407,8 +424,62 @@ _HighlightResult _computeHighlight(List args) { return _HighlightResult(formatted, lines.length, lineTokens); } -/// Global cache keyed by data identity. -final _highlightCache = {}; +/// Global cache keyed by data identity. Bounded to ~10 MB — old entries +/// are evicted in LRU order once the budget is exceeded. +final _highlightCache = LruCache( + maxBytes: 10 * 1024 * 1024, + weightOf: (r) => r.formatted.length * 2 + // string chars ~2 bytes UTF-16 + r.lineTokens.length * 48, // per-line List overhead estimate +); + +/// Background-time tracker. When the app is paused/hidden for longer than +/// [_idleClearThreshold], [_highlightCache] is cleared on resume so that +/// stale (and possibly stale-by-data-version) entries don't linger. +class HighlightCacheLifecycleObserver with WidgetsBindingObserver { + static const Duration _idleClearThreshold = Duration(minutes: 20); + static final HighlightCacheLifecycleObserver instance = + HighlightCacheLifecycleObserver._(); + + DateTime? _pausedAt; + + HighlightCacheLifecycleObserver._(); + + void attach() { + WidgetsBinding.instance.addObserver(this); + } + + void detach() { + WidgetsBinding.instance.removeObserver(this); + } + + /// Drops every cached highlight. Call when the data context changes + /// (device switch, project switch, etc.) so stale values aren't reused. + void clearCache() => _highlightCache.clear(); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _pausedAt ??= DateTime.now(); + break; + case AppLifecycleState.resumed: + final pausedAt = _pausedAt; + _pausedAt = null; + if (pausedAt != null) { + final elapsed = DateTime.now().difference(pausedAt); + if (elapsed >= _idleClearThreshold) { + _highlightCache.clear(); + } + } + break; + case AppLifecycleState.inactive: + // Transient state (e.g. incoming call, control center) — keep timer. + break; + } + } +} class JsonPrettyViewer extends StatefulWidget { final dynamic data; @@ -428,9 +499,15 @@ class _JsonPrettyViewerState extends State { /// Per-line TextSpan cache — built lazily per visible line. final Map> _lineSpanCache = {}; final _scrollController = SmoothScrollController(); + /// The cache key for the entry currently displayed. Pinned so a cache + /// pressure from other pages can't evict the value the user is looking at. + int? _pinnedKey; @override void dispose() { + if (_pinnedKey != null) { + _highlightCache.unpin(_pinnedKey!); + } _scrollController.dispose(); super.dispose(); } @@ -438,6 +515,8 @@ class _JsonPrettyViewerState extends State { @override void initState() { super.initState(); + _pinnedKey = _cacheKey; + _highlightCache.pin(_pinnedKey!); _startCompute(); } @@ -445,19 +524,32 @@ class _JsonPrettyViewerState extends State { void didUpdateWidget(JsonPrettyViewer oldWidget) { super.didUpdateWidget(oldWidget); if (!identical(oldWidget.data, widget.data)) { + // Unpin the previous entry so it becomes evictable again. + if (_pinnedKey != null) { + _highlightCache.unpin(_pinnedKey!); + } + _pinnedKey = _cacheKey; + _highlightCache.pin(_pinnedKey!); _result = null; _lineSpanCache.clear(); _startCompute(); + } else { + // Same data — recompute if theme changed (handled in build) but + // never show the spinner for an unchanged payload. + _loading = _result == null; } } int get _cacheKey => identityHashCode(widget.data); void _startCompute() { - final cached = _highlightCache[_cacheKey]; + final cached = _highlightCache.get(_cacheKey); if (cached != null) { _result = cached; _loading = false; + // Mark the build dirty so the cached result is rendered without + // sitting in the loading state for a frame. + if (mounted) setState(() {}); return; } @@ -468,7 +560,7 @@ class _JsonPrettyViewerState extends State { compute(_computeHighlight, [widget.data, isDark]).then((result) { if (!mounted) return; - _highlightCache[_cacheKey] = result; + _highlightCache.put(_cacheKey, result); setState(() { _result = result; _loading = false; @@ -826,6 +918,184 @@ class ViewModeSegment extends StatelessWidget { } } +/// Premium 3-mode toggle (Tree / JSON / Code). +class ViewModeSwitcher extends StatelessWidget { + final BodyViewMode current; + final String codeLabel; + final ValueChanged onChanged; + + const ViewModeSwitcher({ + super.key, + required this.current, + required this.codeLabel, + required this.onChanged, + }); + + Alignment _alignmentFor(BodyViewMode mode) { + switch (mode) { + case BodyViewMode.tree: + return Alignment.centerLeft; + case BodyViewMode.json: + return Alignment.center; + case BodyViewMode.code: + return Alignment.centerRight; + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final trackColor = isDark + ? const Color(0xFF1C2128).withValues(alpha: 0.6) + : const Color(0xFFEEF0F2); + final trackBorder = isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04); + final thumbColor = isDark ? const Color(0xFF30363D) : Colors.white; + final thumbShadow = isDark + ? Colors.black.withValues(alpha: 0.35) + : Colors.black.withValues(alpha: 0.06); + + return Container( + height: 30, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: trackColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: trackBorder, width: 1), + ), + child: Stack( + children: [ + AnimatedAlign( + alignment: _alignmentFor(current), + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + child: FractionallySizedBox( + widthFactor: 1 / 3, + heightFactor: 1, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.5), + child: Container( + decoration: BoxDecoration( + color: thumbColor, + borderRadius: BorderRadius.circular(6), + boxShadow: [ + BoxShadow( + color: thumbShadow, + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + ), + ), + ), + ), + Row( + children: [ + _Segment( + mode: BodyViewMode.tree, + current: current, + icon: LucideIcons.gitBranch, + label: 'Tree', + onTap: onChanged, + ), + _Segment( + mode: BodyViewMode.json, + current: current, + icon: LucideIcons.braces, + label: 'JSON', + onTap: onChanged, + ), + _Segment( + mode: BodyViewMode.code, + current: current, + icon: LucideIcons.code, + label: codeLabel, + onTap: onChanged, + ), + ], + ), + ], + ), + ); + } +} + +class _Segment extends StatefulWidget { + final BodyViewMode mode; + final BodyViewMode current; + final IconData icon; + final String label; + final ValueChanged onTap; + + const _Segment({ + required this.mode, + required this.current, + required this.icon, + required this.label, + required this.onTap, + }); + + @override + State<_Segment> createState() => _SegmentState(); +} + +class _SegmentState extends State<_Segment> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final isActive = widget.mode == widget.current; + final color = isActive + ? ColorTokens.primary + : (isDark ? Colors.white60 : Colors.black54); + + return Expanded( + child: GestureDetector( + onTap: () => widget.onTap(widget.mode), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: _hovered && !isActive + ? (isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.03)) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, size: 11, color: color), + const SizedBox(width: 5), + Text( + widget.label, + style: TextStyle( + fontSize: 11, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, + color: color, + fontFamily: AppConstants.monoFontFamily, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + // ═══════════════════════════════════════════════════════════════════ // Code viewer — renders the generator's output as two stacked panels: // 1. "Types" — a define-file-style block of named type declarations @@ -1010,3 +1280,339 @@ class _CodePanel extends StatelessWidget { ); } } + +/// Shows a loading spinner for 1 frame on mount, then builds the child. +/// +/// Use with a [ValueKey] tied to a view mode so that switching modes +/// unmounts → remounts → defers → builds, preventing synchronous heavy +/// widget trees from blocking the tab-switch animation frame. +/// +/// Example: +/// ```dart +/// DeferredBuilder( +/// key: ValueKey(currentMode), +/// builder: (_) => JsonViewer(data: parsed), +/// ) +/// ``` +class DeferredBuilder extends StatefulWidget { + final WidgetBuilder builder; + const DeferredBuilder({super.key, required this.builder}); + + @override + State createState() => _DeferredBuilderState(); +} + +class _DeferredBuilderState extends State { + bool _ready = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _ready = true); + }); + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( + padding: const EdgeInsets.all(32), + alignment: Alignment.center, + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.grey[600] : Colors.grey[400], + ), + ), + ); + } + return widget.builder(context); + } +} + +/// Helper widget that ensures heavy JSON content never blocks tab transitions. +/// +/// **How it works:** +/// 1. On mount (or when data changes), it immediately shows a loading spinner. +/// 2. After 1 frame (`addPostFrameCallback`), it parses the JSON (sync for +/// small payloads, isolate for large ones). +/// 3. After parsing completes, it waits 1 more frame before calling [builder] +/// so the spinner is visible and the parent layout has settled. +/// +/// This guarantees the tab-switch animation completes smoothly before any +/// heavy widget tree (JsonViewer, JsonPrettyViewer, CodeViewer) is built. +class AsyncJsonParser extends StatefulWidget { + final dynamic rawData; + final Widget Function(BuildContext context, dynamic parsedData, bool isJson) builder; + + const AsyncJsonParser({ + super.key, + required this.rawData, + required this.builder, + }); + + @override + State createState() => _AsyncJsonParserState(); +} + +class _AsyncJsonParserState extends State { + dynamic _parsedData; + bool _isJson = false; + bool _ready = false; // true once we can call builder + dynamic _lastRawData; + + @override + void initState() { + super.initState(); + _scheduleProcess(); + } + + @override + void didUpdateWidget(AsyncJsonParser oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.rawData, widget.rawData)) { + _scheduleProcess(); + } + } + + /// Defers processing by 1 frame so the current build (tab switch, + /// navigation push, etc.) finishes and paints the loading spinner first. + /// For data that needs no async work (null, Map, List, non-JSON strings, + /// short JSON strings) we resolve synchronously and skip the spinner + /// entirely — flashing a spinner on every click would clobber the selected + /// tile's highlight and make the panel feel sluggish. + void _scheduleProcess() { + _lastRawData = widget.rawData; + final raw = widget.rawData; + + // Fast path: data that can be resolved without an isolate. + // We resolve synchronously and stay _ready=true so no spinner is shown. + if (_tryResolveSync(raw)) { + // _tryResolveSync already set _parsedData/_isJson and _ready=true. + if (mounted) setState(() {}); + return; + } + + // Slow path: large string needing isolate parsing. Show spinner. + setState(() => _ready = false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _processData(); + }); + } + + /// Tries to resolve [raw] synchronously. Returns true if it succeeded + /// (caller should skip the spinner). Returns false if the data needs + /// background isolate parsing. + bool _tryResolveSync(dynamic raw) { + if (raw == null) { + _parsedData = null; + _isJson = false; + _ready = true; + return true; + } + if (raw is Map || raw is List) { + _parsedData = raw; + _isJson = true; + _ready = true; + return true; + } + if (raw is String) { + final trimmed = raw.trim(); + if (trimmed.isEmpty || (trimmed[0] != '{' && trimmed[0] != '[')) { + _parsedData = raw; + _isJson = false; + _ready = true; + return true; + } + if (trimmed.length < 10000) { + dynamic parsed; + try { + parsed = jsonDecode(trimmed); + } catch (_) {} + _parsedData = parsed ?? raw; + _isJson = parsed is Map || parsed is List; + _ready = true; + return true; + } + } + // String ≥ 10k chars that LOOKS like JSON — defer to async path so + // the slow-path in [_processData] gets a chance to run on an isolate. + // Until the isolate finishes, mark as not-ready so callers see the + // spinner rather than rendering the raw string as text. + if (raw is String) { + final t = raw.trim(); + if (t.length >= 10000 && (t.startsWith('{') || t.startsWith('['))) { + return false; // let [_scheduleProcess] take the async path + } + } + // Fallback for other types: resolve sync, no spinner. + _parsedData = raw; + _isJson = false; + _ready = true; + return true; + } + + void _processData() { + final raw = widget.rawData; + if (!identical(raw, _lastRawData)) return; // stale + + // Should have been handled by the fast path; double-check. + if (raw is String) { + final trimmed = raw.trim(); + if (trimmed.length >= 10000 && + trimmed.isNotEmpty && + (trimmed[0] == '{' || trimmed[0] == '[')) { + compute(_decodeJsonIsolate, trimmed).then((parsed) { + if (!mounted || !identical(_lastRawData, raw)) return; + _finalize(parsed ?? raw, parsed is Map || parsed is List); + }).catchError((_) { + if (!mounted || !identical(_lastRawData, raw)) return; + _finalize(raw, false); + }); + return; + } + } + // Otherwise: resolve synchronously and flip ready. + _tryResolveSync(raw); + if (mounted) setState(() {}); + } + + /// Commits the parsed result and flips [_ready] after one more frame so + /// the spinner has at least one paint cycle visible. + void _finalize(dynamic data, bool isJson) { + _parsedData = data; + _isJson = isJson; + // Post-frame so the spinner is visible for at least 1 frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _ready = true); + }); + } + + static dynamic _decodeJsonIsolate(String text) { + try { + return jsonDecode(text); + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( + padding: const EdgeInsets.all(32), + alignment: Alignment.center, + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.grey[600] : Colors.grey[400], + ), + ), + ); + } + return widget.builder(context, _parsedData, _isJson); + } +} + +/// A widget that defers the initialization and rendering of a tab's child +/// until the tab controller actually selects it (making it active/visible). +class LazyTab extends StatefulWidget { + final TabController? controller; + final int index; + final WidgetBuilder builder; + + const LazyTab({ + super.key, + this.controller, + required this.index, + required this.builder, + }); + + @override + State createState() => _LazyTabState(); +} + +class _LazyTabState extends State { + bool _initialized = false; + TabController? _controller; + + @override + void initState() { + super.initState(); + // We will resolve the controller in didChangeDependencies + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final newController = widget.controller ?? DefaultTabController.of(context); + if (_controller != newController) { + _controller?.removeListener(_handleTabChange); + _controller = newController; + _controller?.addListener(_handleTabChange); + _checkVisibility(); + } + } + + @override + void didUpdateWidget(LazyTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _controller?.removeListener(_handleTabChange); + _controller = widget.controller ?? DefaultTabController.of(context); + _controller?.addListener(_handleTabChange); + } + _checkVisibility(); + } + + @override + void dispose() { + _controller?.removeListener(_handleTabChange); + super.dispose(); + } + + void _handleTabChange() { + if (!mounted) return; + _checkVisibility(); + } + + void _checkVisibility() { + final c = _controller; + if (c != null && !_initialized && c.index == widget.index) { + setState(() { + _initialized = true; + }); + } + } + + @override + Widget build(BuildContext context) { + if (!_initialized) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( + alignment: Alignment.center, + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: isDark ? Colors.grey[600] : Colors.grey[400], + ), + ), + ); + } + return widget.builder(context); + } +} + + + diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 097e33d..e967683 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -1,10 +1,29 @@ class AppConstants { static const String appName = 'DevConnect Manage Tool'; - static const String appVersion = '1.0.1'; + static const String appVersion = '1.0.2'; static const String monoFontFamily = 'JetBrains Mono'; static const int defaultPort = 9090; static const int heartbeatIntervalMs = 5000; static const int heartbeatTimeoutMs = 10000; static const int maxLogEntries = 10000; static const int maxNetworkEntries = 5000; + + /// Binary base for byte-size formatting. 1024 = KiB convention used by most + /// dev tools (KB label intentionally — matches what users expect to see). + static const int bytesPerKb = 1024; + + /// Formats [bytes] as a human-readable string with binary suffix: + /// "523 B" / "4.2 KB" / "8.1 MB" / "1.5 GB". + /// + /// Uses [bytesPerKb] as the base so the whole app stays consistent if the + /// base is ever swapped (e.g. for true KiB labels). + static String formatBytes(int bytes) { + if (bytes < bytesPerKb) return '$bytes B'; + final kb = bytes / bytesPerKb; + if (kb < bytesPerKb) return '${kb.toStringAsFixed(1)} KB'; + final mb = kb / bytesPerKb; + if (mb < bytesPerKb) return '${mb.toStringAsFixed(1)} MB'; + final gb = mb / bytesPerKb; + return '${gb.toStringAsFixed(2)} GB'; + } } diff --git a/lib/core/routes/app_shell.dart b/lib/core/routes/app_shell.dart index c32f73b..f0e1312 100644 --- a/lib/core/routes/app_shell.dart +++ b/lib/core/routes/app_shell.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:window_manager/window_manager.dart'; +import '../../components/feedback/lib_update_tips.dart'; import '../../components/layout/device_bottom_bar.dart'; import '../../components/layout/sidebar.dart'; import '../providers/tab_visibility_provider.dart'; @@ -113,6 +114,16 @@ class _AppShellState extends ConsumerState { }, ), ), + // ── Update tips pill (anchored to OS title bar, top-right) ── + // Sits in the title-bar strip so it stays above every + // page's toolbar and never overlaps page content. + // macOS traffic lights live in the top-left ~80px so + // right: 14 here is safely clear of them. + Positioned( + top: Platform.isMacOS ? 20 : 6, + right: 14, + child: const LibUpdateTips(), + ), ], ); }, diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart index 8968571..2d7c5a5 100644 --- a/lib/core/theme/theme_provider.dart +++ b/lib/core/theme/theme_provider.dart @@ -62,6 +62,12 @@ final bodyViewModeProvider = (ref) => BodyViewModeNotifier(), ); +/// View mode for the metadata block in detail panels. Independent from +/// [bodyViewModeProvider] so switching the metadata render style doesn't +/// flip the message block above it (and vice versa). +final metadataViewModeProvider = + StateProvider((ref) => BodyViewMode.tree); + /// Whether tab switching animation is enabled in detail panels. class TabAnimationEnabledNotifier extends StateNotifier { TabAnimationEnabledNotifier() diff --git a/lib/core/utils/log_message_summary.dart b/lib/core/utils/log_message_summary.dart index d2c3ce0..9d09733 100644 --- a/lib/core/utils/log_message_summary.dart +++ b/lib/core/utils/log_message_summary.dart @@ -14,18 +14,20 @@ String summarizeLogMessage(String message) { 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; + // Cheap shape check before paying the cost of a full JSON decode. + // For very large payloads, return a simple cover without key names. + const largePayloadThreshold = 1000; + if (trimmed.length > largePayloadThreshold) { + final kb = (trimmed.length / 1024).toStringAsFixed(1); + if (trimmed[0] == '{') return 'Object {…} ($kb KB)'; + return 'Array […] ($kb KB)'; + } - // 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 + return message; } if (parsed is Map) { diff --git a/lib/core/utils/lru_cache.dart b/lib/core/utils/lru_cache.dart new file mode 100644 index 0000000..81d5cf0 --- /dev/null +++ b/lib/core/utils/lru_cache.dart @@ -0,0 +1,158 @@ +import 'dart:collection'; + +/// A simple LRU (least-recently-used) cache with a byte-based size cap +/// and opt-in pinning for entries that must survive eviction. +/// +/// Sizing is approximate: each entry's "weight" is whatever the caller +/// reports via [weightOf]. The cache evicts the oldest non-pinned entries +/// whenever the total reported weight exceeds [maxBytes]. Pinned entries +/// are preserved across evictions; only when EVERY remaining entry is +/// pinned do we fall back to evicting from the head. +/// +/// Insertion order is preserved by [LinkedHashMap]'s iteration order — +/// `get` re-inserts the entry to mark it as most-recently used, and +/// eviction removes from the head. +/// +/// Usage: +/// ```dart +/// final cache = LruCache( +/// maxBytes: 10 * 1024 * 1024, +/// weightOf: (v) => v.estimatedBytes, +/// ); +/// cache.put(1, value); +/// cache.pin(1); // entry 1 will survive subsequent evictions +/// ``` +class LruCache { + /// Maximum total reported weight before eviction kicks in. + final int maxBytes; + + /// Returns the weight of a single cached value (bytes). + final int Function(V value) weightOf; + + /// Optional callback invoked once per evicted entry. Useful for logging + /// or for releasing external resources held by the value. + final void Function(K key, V value)? onEvict; + + final LinkedHashMap _map = LinkedHashMap(); + final Set _pinned = {}; + int _currentBytes = 0; + + LruCache({ + required this.maxBytes, + required this.weightOf, + this.onEvict, + }); + + /// Number of entries currently cached. + int get length => _map.length; + + /// Sum of weights reported by all entries. Approximate. + int get currentBytes => _currentBytes; + + /// Returns the cached value for [key] and marks it as most-recently used, + /// or `null` if absent. + V? get(K key) { + final v = _map.remove(key); + if (v == null) return null; + _map[key] = v; // re-insert → moves to tail (most-recently used) + return v; + } + + /// Returns true if [key] is in the cache. Does NOT mark as recently used. + bool containsKey(K key) => _map.containsKey(key); + + /// Returns true if [key] is currently pinned (immune to eviction). + bool isPinned(K key) => _pinned.contains(key); + + /// Marks [key] as pinned — its entry will not be evicted by [put] unless + /// every remaining entry is also pinned (in which case eviction falls + /// back to LRU). + /// + /// Pinning is idempotent. The pin survives even if [key] is not yet in + /// the cache — a subsequent [put] for the same key will be pinned + /// automatically. This matters when callers pin during [initState] but + /// the entry is inserted asynchronously by a later [put]. + void pin(K key) { + _pinned.add(key); + } + + /// Removes the pin on [key]. The entry becomes evictable again. Does + /// not remove the entry itself. + void unpin(K key) { + _pinned.remove(key); + } + + /// Inserts or replaces [key]'s value. If the new entry alone exceeds + /// [maxBytes], the entry is refused to keep memory bounded. + /// + /// Eviction walks the map from the head (least-recently-used) and + /// removes the first non-pinned entry. If every remaining entry is + /// pinned, falls back to evicting from the head regardless. + void put(K key, V value) { + final w = weightOf(value); + if (w >= maxBytes) { + // Refuse pathological entries that would dominate the budget. + _map.remove(key); + _pinned.remove(key); + _currentBytes = _totalWeight(); + return; + } + + // Replace existing entry — adjust byte count first. + final existing = _map.remove(key); + if (existing != null) { + _currentBytes -= weightOf(existing); + } + + _map[key] = value; + _currentBytes += w; + + // Evict from head until under budget. Skip pinned entries; fall back + // to head-of-map only if everything left is pinned. + while (_currentBytes > maxBytes && _map.isNotEmpty) { + K? victimKey; + for (final k in _map.keys) { + if (!_pinned.contains(k)) { + victimKey = k; + break; + } + } + // All entries pinned — evict head anyway to honor the budget. + victimKey ??= _map.keys.first; + final victimValue = _map.remove(victimKey); + _pinned.remove(victimKey); + if (victimValue != null && victimKey != null) { + _currentBytes -= weightOf(victimValue); + onEvict?.call(victimKey, victimValue); + } + } + } + + /// Removes [key]. Returns the removed value, or `null` if absent. + V? remove(K key) { + _pinned.remove(key); + final v = _map.remove(key); + if (v != null) _currentBytes -= weightOf(v); + return v; + } + + /// Empties the cache, invoking [onEvict] for every removed entry. + void clear() { + if (onEvict != null) { + for (final e in _map.entries) { + onEvict!(e.key, e.value); + } + } + _map.clear(); + _pinned.clear(); + _currentBytes = 0; + } + + int _totalWeight() { + var total = 0; + for (final v in _map.values) { + total += weightOf(v); + } + return total; + } +} \ No newline at end of file diff --git a/lib/core/utils/network_service_detector.dart b/lib/core/utils/network_service_detector.dart new file mode 100644 index 0000000..a3166ae --- /dev/null +++ b/lib/core/utils/network_service_detector.dart @@ -0,0 +1,71 @@ +/// Detects the backend service behind a network request and, where possible, +/// extracts a human-readable action name (e.g. AWS Cognito's +/// `AWSCognitoIdentityProviderService.GetUser`). +library; + +class DetectedService { + final String name; + final String? action; + const DetectedService(this.name, {this.action}); +} + +const _services = <_ServiceRule>[ + _ServiceRule('Google Maps', ['maps.googleapis.com', 'maps.google.com']), + _ServiceRule('Firebase', [ + 'firebaseio.com', + 'firebasestorage.googleapis.com', + 'identitytoolkit.googleapis.com', + 'fcm.googleapis.com', + ]), + _ServiceRule('AWS Cognito', [ + 'cognito-idp.', + 'cognito-identity.', + 'cognito-sync.', + ]), + _ServiceRule('AWS', ['amazonaws.com']), + _ServiceRule('Stripe', ['stripe.com', 'stripe.network']), + _ServiceRule('GitHub', ['api.github.com']), + _ServiceRule('Sentry', ['sentry.io', 'ingest.sentry.io']), + _ServiceRule('Mixpanel', ['mixpanel.com']), + _ServiceRule('Segment', ['segment.io', 'segment.com']), + _ServiceRule('Amplitude', ['amplitude.com', 'api.amplitude.com']), +]; + +class _ServiceRule { + final String name; + final List patterns; + const _ServiceRule(this.name, this.patterns); +} + +DetectedService? detectService(String url, {Map? headers, dynamic body}) { + final lower = url.toLowerCase(); + for (final s in _services) { + if (s.patterns.any((p) => lower.contains(p))) { + final action = _extractAction(s.name, url, headers, body); + return DetectedService(s.name, action: action); + } + } + return null; +} + +String? _extractAction(String service, String url, Map? headers, dynamic body) { + final h = _normalizeHeaders(headers); + final amzTarget = h['x-amz-target'] ?? h['amz-target']; + if (amzTarget != null && amzTarget.isNotEmpty) return amzTarget; + + if (service == 'AWS Cognito' && body is String) { + final match = RegExp(r'\s*([^<]+)\s*', caseSensitive: false) + .firstMatch(body); + if (match != null) return match.group(1)?.trim(); + } + if (body is String) { + final sigMatch = RegExp(r'"Action"\s*:\s*"([^"]+)"').firstMatch(body); + if (sigMatch != null) return sigMatch.group(1); + } + return null; +} + +Map _normalizeHeaders(Map? h) { + if (h == null) return const {}; + return {for (final e in h.entries) e.key.toLowerCase(): e.value}; +} diff --git a/lib/core/utils/network_url_utils.dart b/lib/core/utils/network_url_utils.dart new file mode 100644 index 0000000..836c22d --- /dev/null +++ b/lib/core/utils/network_url_utils.dart @@ -0,0 +1,38 @@ +/// Helpers for normalising network-request URLs coming off the wire. +/// +/// Older or poorly-behaved client SDKs sometimes send a value that is not +/// a real URL string — most commonly the default JS +/// `Object.prototype.toString()` rendering of a Request object: +/// `"[object Object]"`, or a URL-encoded variant like +/// `"%5Bobject%20Object%5D"`. We surface those as a clear placeholder so +/// the inspector list/detail panels don't show a wall of encoded +/// brackets. +library; + +/// Returns true if [url] is a string but does not look like a real URL. +/// Detects: +/// - The classic JS `Object.prototype.toString()` rendering. +/// - Empty strings. +/// - Strings that don't contain either `://` or start with `/` (i.e. a +/// relative path, which is still meaningful), AND have no host-like +/// component. We deliberately keep the check loose so legitimate +/// relative paths like `/api/users` still pass through. +bool isMalformedNetworkUrl(String? url) { + if (url == null) return true; + final trimmed = url.trim(); + if (trimmed.isEmpty) return true; + if (trimmed == '[object Object]') return true; + // URL-encoded variant — also matches things like + // `%5Bobject%20Object%5D` or `%5Bobject Object%5D`. + if (trimmed.toLowerCase().contains('[object') && + trimmed.toLowerCase().contains('object]')) { + return true; + } + if (trimmed.toLowerCase() == '%5bobject%20object%5d') return true; + return false; +} + +/// Normalise a raw URL value coming off the wire. Returns +/// `''` for values that aren't usable URLs. +String normalizeNetworkUrl(String? url) => + isMalformedNetworkUrl(url) ? '' : url!.trim(); diff --git a/lib/core/utils/screenshot_filename.dart b/lib/core/utils/screenshot_filename.dart new file mode 100644 index 0000000..7b5cae0 --- /dev/null +++ b/lib/core/utils/screenshot_filename.dart @@ -0,0 +1,66 @@ +/// Sanitizes a string so it can be safely embedded in a screenshot filename. +/// +/// - Replaces any character that is not [A-Za-z0-9_-] with `_`. +/// - Collapses runs of underscores into one. +/// - Trims leading/trailing underscores. +/// - Truncates to [maxLen] characters so the final name + suffix stays +/// under typical filesystem limits. +String safeFileName(String raw, {int maxLen = 40}) { + if (raw.isEmpty) return 'item'; + final cleaned = raw.replaceAll(RegExp(r'[^A-Za-z0-9_-]+'), '_'); + final trimmed = cleaned.replaceAll(RegExp(r'_+'), '_'); + final stripped = + trimmed.replaceAll(RegExp(r'^_+|_+$'), ''); + if (stripped.isEmpty) return 'item'; + return stripped.length > maxLen ? stripped.substring(0, maxLen) : stripped; +} + +/// Builds a screenshot filename from a [prefix] (e.g. `storage_data`) and a +/// subject identifier (e.g. a storage key or network URL). +/// +/// Example: +/// ```dart +/// buildScreenshotName('storage_data', 'user:session-token'); +/// // → 'storage_data_user_session-token.png' +/// ``` +String buildScreenshotName(String prefix, String subject) { + final safe = safeFileName(subject); + return '${prefix}_$safe'; +} + +/// Builds a unified screenshot filename with the form +/// `___.png`. +/// +/// The app name is intentionally **not** included — screenshots may be +/// shared with clients and the internal app identifier must not leak. +/// +/// - [type] is a short category label (e.g. `log`, `network`, `state`, +/// `storage`, `error`, `display`). +/// - [subject] is a meaningful identifier — a storage key, a network URL +/// path, a log tag, etc. It's sanitized via [safeFileName]. +/// - [suffix] disambiguates the capture kind (e.g. `_full`, `_data`, +/// `_detail`). +/// +/// Example: +/// ```dart +/// buildRichScreenshotName( +/// type: 'network', +/// subject: 'https://api.example.com/v1/users', +/// suffix: '_full', +/// ); +/// // → 'network_api_example_com_v1_users_2026-07-04T19-52-47_full.png' +/// ``` +String buildRichScreenshotName({ + required String type, + required String subject, + required String suffix, +}) { + final t = safeFileName(type); + final s = safeFileName(subject); + final ts = DateTime.now() + .toIso8601String() + .replaceAll(':', '-') + .split('.') + .first; + return '${t}_${s}_${ts}$suffix'; +} \ No newline at end of file diff --git a/lib/core/utils/screenshot_utils.dart b/lib/core/utils/screenshot_utils.dart index 8d49cd9..3a17ed6 100644 --- a/lib/core/utils/screenshot_utils.dart +++ b/lib/core/utils/screenshot_utils.dart @@ -1,16 +1,37 @@ -import 'dart:io'; import 'dart:ui' as ui; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -/// Captures a widget as a PNG image and saves to file. -Future captureWidgetAsImage( +import 'toast_utils.dart'; + +/// Captures a widget as a PNG image and saves to file. Returns the saved +/// file path on success, or null if the user cancelled the save dialog or +/// capture failed. +/// +/// [fileName] is the suggested filename shown in the save dialog. If null, +/// a timestamp-based default is used (e.g. `dcmt_1717456789.png`). +/// Callers should pass a meaningful name so the user can recognise the +/// context of the screenshot — e.g. `storage_data_user_token`. +/// +/// [pixelRatio] defaults to 3.0 for crisp Retina-class output on +/// macOS/Windows displays. Lower if the screenshot is too large to share. +/// +/// [onSaved] is called with the saved path after the file is written. If +/// omitted, the rich "Screenshot saved · Reveal" overlay toast is shown. +/// +/// Implementation note: we use [XFile] + [saveTo] (rather than `File.writeAsBytes`) +/// so the user-selected path is treated as authoritative — on macOS the OS +/// won't silently append timestamps or rename to `image.png` after the dialog +/// closes. +Future captureWidgetAsImage( BuildContext context, Widget screenshotWidget, { double width = 600, - double pixelRatio = 2.0, + double pixelRatio = 3.0, + String? fileName, + void Function(String savedPath)? onSaved, }) async { try { // Show flash @@ -40,13 +61,15 @@ Future captureWidgetAsImage( ); Overlay.of(context).insert(overlayEntry); - await Future.delayed(const Duration(milliseconds: 300)); + // Wait long enough for async widgets (e.g. JsonPrettyViewer's + // isolate compute) to settle and paint before snapshotting. + await Future.delayed(const Duration(milliseconds: 600)); final boundary = overlayKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) { overlayEntry.remove(); - return; + return null; } final image = await boundary.toImage(pixelRatio: pixelRatio); @@ -54,41 +77,68 @@ Future captureWidgetAsImage( await image.toByteData(format: ui.ImageByteFormat.png); overlayEntry.remove(); - if (byteData == null) return; + if (byteData == null) return null; final pngBytes = byteData.buffer.asUint8List(); - final fileName = - 'dcmt_${DateTime.now().millisecondsSinceEpoch}.png'; + final baseName = (fileName == null || fileName.isEmpty) + ? 'dcmt_${DateTime.now().millisecondsSinceEpoch}' + : fileName; + final withExt = + baseName.endsWith('.png') ? baseName : '$baseName.png'; + final location = await getSaveLocation( - suggestedName: fileName, + suggestedName: withExt, acceptedTypeGroups: [ const XTypeGroup(label: 'PNG Image', extensions: ['png']), ], ); - if (location == null) return; + if (location == null) return null; - final file = File(location.path); - await file.writeAsBytes(pngBytes); + // Force the saved file's name to [withExt] regardless of what the OS + // returns in [location.path] — some platforms append timestamps or + // strip extensions after the dialog closes. + final savedPath = _ensureFilename(location.path, withExt); + final xfile = XFile.fromData( + pngBytes, + mimeType: 'image/png', + name: withExt, + length: pngBytes.lengthInBytes, + ); + await xfile.saveTo(savedPath); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Screenshot saved: ${file.path}'), - duration: const Duration(seconds: 2), - ), - ); + if (onSaved != null) { + onSaved(savedPath); + } else { + // Use the rich overlay toast defined in toast_utils.dart so + // every page shows the same "Screenshot saved · Reveal" pill. + showScreenshotSavedToast(context, filePath: savedPath); + } } + return savedPath; } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Screenshot failed: $e')), ); } + return null; } } +/// Returns [path] but with its basename replaced by [desiredName] when the +/// user picked a folder via the save dialog. This keeps our naming +/// convention (`storage_data_user_token.png`) even if the OS would otherwise +/// default the new file to `image.png` or add a numeric suffix. +String _ensureFilename(String path, String desiredName) { + final sep = path.contains(r'\') ? r'\' : '/'; + final last = path.lastIndexOf(sep); + if (last == -1) return '$path$sep$desiredName'; + return '${path.substring(0, last + 1)}$desiredName'; +} + void _showCaptureFlash(BuildContext context) { final overlay = Overlay.of(context); late OverlayEntry flashEntry; @@ -107,3 +157,4 @@ void _showCaptureFlash(BuildContext context) { ); overlay.insert(flashEntry); } + 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 17db9e9..3c7ac18 100644 --- a/lib/features/all_events/presentation/pages/all_events_page.dart +++ b/lib/features/all_events/presentation/pages/all_events_page.dart @@ -4,6 +4,8 @@ import 'dart:ui' as ui; import 'package:file_selector/file_selector.dart'; import '../../../../core/utils/duration_format.dart'; +import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../core/utils/screenshot_filename.dart'; import 'package:flutter/material.dart'; import '../../../../l10n/app_localizations.dart'; import 'package:flutter/rendering.dart'; @@ -18,6 +20,7 @@ import '../../../../components/feedback/empty_state.dart'; import '../../../../components/inputs/search_field.dart'; import '../../../../components/text/text_component.dart'; import '../../../../components/misc/status_badge.dart'; +import '../../../../components/misc/service_tag.dart'; import '../../../../components/viewers/json_viewer.dart'; import '../../../../core/providers/tab_visibility_provider.dart'; import '../../../../core/theme/color_tokens.dart'; @@ -995,6 +998,13 @@ class _EventRow extends StatelessWidget { ), ), const SizedBox(width: 8), + if (event.type == EventType.network && + event.rawData is NetworkEntry && + (event.rawData as NetworkEntry).serviceName != null) ...[ + ServiceTag( + name: (event.rawData as NetworkEntry).serviceName!), + const SizedBox(width: 6), + ], // Subtitle / Status indicator if (!showDetail) ...[ if (event.type == EventType.network && @@ -2456,10 +2466,10 @@ class _EventDetailPanel extends StatefulWidget { class _EventDetailPanelState extends State<_EventDetailPanel> { int _currentTabIndex = 0; bool _currentJsonMode = false; - bool _storageFormatted = false; final _contentKey = GlobalKey(); - Future _captureAndSave(Widget screenshotWidget) async { + Future _captureAndSave(Widget screenshotWidget, + {String? fileName}) async { try { // Show capture flash animation _showCaptureFlash(); @@ -2488,7 +2498,7 @@ class _EventDetailPanelState extends State<_EventDetailPanel> { ); Overlay.of(context).insert(overlayEntry); - await Future.delayed(const Duration(milliseconds: 300)); + await Future.delayed(const Duration(milliseconds: 600)); final boundary = overlayKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; @@ -2506,10 +2516,14 @@ class _EventDetailPanelState extends State<_EventDetailPanel> { final pngBytes = byteData.buffer.asUint8List(); - final fileName = - 'dcmt_${DateTime.now().millisecondsSinceEpoch}.png'; + final baseName = (fileName == null || fileName.isEmpty) + ? 'dcmt_${DateTime.now().millisecondsSinceEpoch}' + : fileName; + final withExt = + baseName.endsWith('.png') ? baseName : '$baseName.png'; + final location = await getSaveLocation( - suggestedName: fileName, + suggestedName: withExt, acceptedTypeGroups: [ const XTypeGroup(label: 'PNG Image', extensions: ['png']), ], @@ -2517,15 +2531,29 @@ class _EventDetailPanelState extends State<_EventDetailPanel> { if (location == null) return; - final file = File(location.path); - await file.writeAsBytes(pngBytes); + // Force saved file's name to withExt regardless of what OS returns. + final savedPath = _ensureFilename(location.path, withExt); + final xfile = XFile.fromData( + pngBytes, + mimeType: 'image/png', + name: withExt, + length: pngBytes.lengthInBytes, + ); + await xfile.saveTo(savedPath); - if (mounted) _showSavedToast(file.path); + if (mounted) showScreenshotSavedToast(context, filePath: savedPath); } catch (e) { if (mounted) _showErrorToast('$e'); } } + String _ensureFilename(String path, String desiredName) { + final sep = path.contains(r'\') ? r'\' : '/'; + final last = path.lastIndexOf(sep); + if (last == -1) return '$path$sep$desiredName'; + return '${path.substring(0, last + 1)}$desiredName'; + } + void _showCaptureFlash() { final overlay = Overlay.of(context); late OverlayEntry flashEntry; @@ -2788,7 +2816,46 @@ class _EventDetailPanelState extends State<_EventDetailPanel> { Future _takeFullScreenshot() async { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - await _captureAndSave(_buildScreenshotWidget(theme, isDark)); + final widget_ = _buildScreenshotWidget(theme, isDark); + final fileName = _buildEventScreenshotName('_full'); + await _captureAndSave(widget_, fileName: fileName); + } + + /// Builds a descriptive file name for event screenshots: + /// `___.png` + /// Falls back gracefully when key metadata is missing. + /// Note: appName is intentionally NOT included — screenshots may be + /// shared with clients and the internal app identifier must not leak. + String _buildEventScreenshotName(String suffix) { + final event = widget.event; + final type = event.type.name; + + // Pick a meaningful subject: storage key, network URL path, log tag, etc. + String subject = event.title; + if (event.rawData is StorageEntry) { + subject = (event.rawData as StorageEntry).key; + } else if (event.rawData is NetworkEntry) { + final url = (event.rawData as NetworkEntry).url; + try { + subject = Uri.parse(url).path.isEmpty ? url : Uri.parse(url).path; + } catch (_) { + subject = url; + } + } else if (event.rawData is LogEntry) { + final tag = (event.rawData as LogEntry).tag; + if (tag != null && tag.isNotEmpty) subject = tag; + } else if (event.rawData is StateChange) { + final sc = event.rawData as StateChange; + subject = sc.actionName.isNotEmpty + ? sc.actionName + : sc.stateManagerType; + } + + return buildRichScreenshotName( + type: type, + subject: subject, + suffix: suffix, + ); } Future _takeTabScreenshot() async { @@ -2900,7 +2967,7 @@ class _EventDetailPanelState extends State<_EventDetailPanel> { color: typeColor, ), ), - const SizedBox(width: 10), + const Spacer(), TextComponent( time, style: TextStyle( @@ -3209,62 +3276,245 @@ class _EventDetailPanelState extends State<_EventDetailPanel> { } Widget _storageScreenshot(StorageEntry entry, bool isDark) { - Color opColor; - switch (entry.operation.toLowerCase()) { - case 'write': - opColor = ColorTokens.success; - break; - case 'read': - opColor = ColorTokens.info; - break; - case 'delete': - case 'clear': - opColor = ColorTokens.error; - break; - default: - opColor = ColorTokens.warning; + // Operation color matches the in-app panel: emerald/blue/red/amber. + Color opColorFor(StorageEntry e) { + switch (e.operation.toLowerCase()) { + case 'write': + return const Color(0xFF34D399); + case 'read': + return const Color(0xFF60A5FA); + case 'delete': + case 'clear': + return const Color(0xFFF87171); + default: + return const Color(0xFFFBBF24); + } } - return Padding( - padding: const EdgeInsets.all(16), + // Resolve platform from connected devices for code-mode export + final devices = ProviderScope.containerOf(context, listen: false) + .read(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == entry.deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); + + // Respect 3 view modes from global provider + final mode = ProviderScope.containerOf(context, listen: false) + .read(bodyViewModeProvider); + + // Design tokens + final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; + final dividerColor = isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06); + + // ── Helpers ───────────────────────────────────────────── + String formatShape() { + final v = entry.value; + if (v == null) return 'null'; + if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; + if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; + if (v is String) { + if (v.isEmpty) return 'String · empty'; + final t = v.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']'))) { + return 'String · JSON-shaped'; + } + return 'String'; + } + return v.runtimeType.toString(); + } + + String formatSize() { + final raw = entry.value is String + ? entry.value as String + : const JsonEncoder.withIndent(' ').convert(entry.value); + return AppConstants.formatBytes(raw.length); + } + + dynamic parseJson() { + final v = entry.value; + if (v is! String) return null; + try { + final p = jsonDecode(v); + if (p is Map || p is List) return p; + } catch (_) {} + return null; + } + + dynamic displayValue() { + final v = entry.value; + if (v is Map || v is List) return v; + return parseJson() ?? v; + } + + bool isJsonLike() { + final v = entry.value; + if (v is Map || v is List) return true; + return parseJson() != null; + } + + Widget buildValueWidget() { + if (!isJsonLike()) { + return _CodeBlock(text: '${entry.value}', isDark: isDark); + } + final value = displayValue(); + return switch (mode) { + BodyViewMode.tree => + JsonViewer(data: value, initiallyExpanded: true), + BodyViewMode.json => JsonPrettyViewer(data: value), + BodyViewMode.code => CodeViewer( + generated: CodeGenerator.generate(value, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ), + }; + } + + // Match the in-app metadata bento grid (2x2: SHAPE/SIZE on row 1, + // DEVICE/CAPTURED on row 2). Each cell has uppercase label + value. + final monoPrimary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + height: 1.5, + color: isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A), + ); + final monoSecondary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + height: 1.5, + color: labelColor, + ); + final metaLabelStyle = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: labelColor, + ); + + Widget metaCell(String label, String value, TextStyle valueStyle, + {bool monospace = false}) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent(label, style: metaLabelStyle), + const SizedBox(height: 4), + TextComponent( + value, + style: monospace + ? valueStyle.copyWith(fontFamily: AppConstants.monoFontFamily) + : valueStyle, + ), + ], + ); + + return Container( + color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - _TagChip(entry.operation.toUpperCase(), color: opColor), - const SizedBox(width: 8), - _TagChip(entry.storageType.name, color: ColorTokens.warning), - ]), - const SizedBox(height: 16), - const _SectionLabel('Key'), - const SizedBox(height: 6), - _CodeBlock(text: entry.key, isDark: isDark), + // ── Badges (mirror _StorageDetailRedesign header row) ── + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _OpBadge(label: entry.operation, color: opColorFor(entry)), + const SizedBox(width: 8), + _TypeBadge(label: entry.storageType.name), + ], + ), + ), + // ── Metadata (matches the in-app bento grid) ────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent('METADATA', style: metaLabelStyle), + const SizedBox(height: 10), + // Row 1: SHAPE / SIZE + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: metaCell('SHAPE', formatShape(), monoPrimary), + ), + const SizedBox(width: 12), + Expanded( + child: metaCell('SIZE', formatSize(), monoPrimary), + ), + ], + ), + const SizedBox(height: 12), + // Row 2: DEVICE / CAPTURED + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: metaCell( + 'DEVICE', entry.deviceId, monoSecondary, + monospace: true), + ), + const SizedBox(width: 12), + Expanded( + child: metaCell( + 'CAPTURED', + DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch( + entry.timestamp), + ), + monoPrimary, + ), + ), + ], + ), + ], + ), + ), + // ── Divider ─────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Container(height: 1, color: dividerColor), + ), + // ── Key ─────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel('Key'), + const SizedBox(height: 6), + _CodeBlock(text: entry.key, isDark: isDark), + ], + ), + ), + // ── Value (3 view modes when JSON-like) ────────────── if (entry.value != null) ...[ const SizedBox(height: 16), - const _SectionLabel('Value'), - const SizedBox(height: 6), - if (entry.value is Map || entry.value is List) - JsonViewer(data: entry.value, initiallyExpanded: true) - else if (_storageFormatted && _tryParseStorageJson(entry.value) != null) - JsonViewer(data: _tryParseStorageJson(entry.value), initiallyExpanded: true) - else - _CodeBlock(text: '${entry.value}', isDark: isDark), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel('Value'), + const SizedBox(height: 6), + buildValueWidget(), + ], + ), + ), ], ], ), ); } - dynamic _tryParseStorageJson(dynamic value) { - if (value is! String) return null; - try { - final parsed = jsonDecode(value); - if (parsed is Map || parsed is List) return parsed; - } catch (_) {} - return null; - } - Widget _fallbackScreenshot(UnifiedEvent event, bool isDark) { return Padding( padding: const EdgeInsets.all(16), @@ -3604,9 +3854,8 @@ class _EventDetailPanelState extends State<_EventDetailPanel> { return _FallbackDetail(event: widget.event); case EventType.storage: if (widget.event.rawData is StorageEntry) { - return _StorageDetail( + return _StorageDetailRedesign( entry: widget.event.rawData as StorageEntry, - onFormatChanged: (v) => _storageFormatted = v, ); } return _FallbackDetail(event: widget.event); @@ -4175,20 +4424,36 @@ class _NetworkDetailState extends ConsumerState<_NetworkDetail> child: TabBarView( controller: _tabController, children: [ - _HeadersView(entry: entry), - _BodyView( - body: entry.requestBody, - label: 'Request Body', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, + LazyTab( + controller: _tabController, + index: 0, + builder: (_) => _HeadersView(entry: entry), + ), + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => _BodyView( + body: entry.requestBody, + label: 'Request Body', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), + ), + LazyTab( + controller: _tabController, + index: 2, + builder: (_) => _BodyView( + body: entry.responseBody, + label: 'Response Body', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), ), - _BodyView( - body: entry.responseBody, - label: 'Response Body', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, + LazyTab( + controller: _tabController, + index: 3, + builder: (_) => _TimingView(entry: entry), ), - _TimingView(entry: entry), ], ), ), @@ -4636,17 +4901,6 @@ class _BodyViewState extends ConsumerState<_BodyView> { return EmptyState(icon: LucideIcons.fileText, title: 'No ${widget.label}'); } - // Try to parse string body as JSON - dynamic parsedBody = widget.body; - if (parsedBody is String) { - try { - parsedBody = jsonDecode(parsedBody); - } catch (_) {} - } - - final canToggle = parsedBody is Map || parsedBody is List; - final effectiveMode = canToggle ? viewMode : BodyViewMode.json; - // Look up the connected device's platform to pick the Code language. final devices = ref.watch(connectedDevicesProvider); final platform = widget.deviceId == null @@ -4658,75 +4912,82 @@ class _BodyViewState extends ConsumerState<_BodyView> { 'react_native'; final codeLang = CodeGenerator.langForPlatform(platform); - return Column( - children: [ - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), + return AsyncJsonParser( + rawData: widget.body, + builder: (context, parsedBody, isJson) { + final canToggle = isJson; + final effectiveMode = canToggle ? viewMode : BodyViewMode.json; + + return Column( + children: [ + Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + _SectionLabel(widget.label), + const Spacer(), + if (canToggle) ...[ + ViewModeSegment( + label: 'Tree', + active: effectiveMode == BodyViewMode.tree, + position: ViewSegmentPosition.start, + onTap: () { + ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.tree); + widget.onJsonModeChanged?.call(false); + }, + ), + ViewModeSegment( + label: 'JSON', + active: effectiveMode == BodyViewMode.json, + position: ViewSegmentPosition.middle, + onTap: () { + ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.json); + widget.onJsonModeChanged?.call(true); + }, + ), + ViewModeSegment( + label: CodeGenerator.labelFor(codeLang), + active: effectiveMode == BodyViewMode.code, + position: ViewSegmentPosition.end, + onTap: () { + ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.code); + widget.onJsonModeChanged?.call(false); + }, + ), + ], + ], ), ), - ), - child: Row( - children: [ - _SectionLabel(widget.label), - const Spacer(), - if (canToggle) ...[ - ViewModeSegment( - label: 'Tree', - active: effectiveMode == BodyViewMode.tree, - position: ViewSegmentPosition.start, - onTap: () { - ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.tree); - widget.onJsonModeChanged?.call(false); - }, - ), - ViewModeSegment( - label: 'JSON', - active: effectiveMode == BodyViewMode.json, - position: ViewSegmentPosition.middle, - onTap: () { - ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.json); - widget.onJsonModeChanged?.call(true); - }, - ), - ViewModeSegment( - label: CodeGenerator.labelFor(codeLang), - active: effectiveMode == BodyViewMode.code, - position: ViewSegmentPosition.end, - onTap: () { - ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.code); - widget.onJsonModeChanged?.call(false); - }, + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildContent( + parsedBody: parsedBody, + canToggle: canToggle, + mode: effectiveMode, + codeLang: codeLang, ), - ], - ], - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: _buildContent( - parsedBody: parsedBody, - canToggle: canToggle, - mode: effectiveMode, - codeLang: codeLang, + ), ), - ), - ), - ], + ], + ); + }, ); } @@ -4739,19 +5000,26 @@ class _BodyViewState extends ConsumerState<_BodyView> { if (!canToggle) { return JsonPrettyViewer(data: parsedBody); } - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: parsedBody, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: parsedBody); - case BodyViewMode.code: - final generated = CodeGenerator.generate(parsedBody, codeLang); - return CodeViewer( - generated: generated, - lang: codeLang, - languageLabel: CodeGenerator.labelFor(codeLang), - ); - } + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsedBody, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: widget.body); + case BodyViewMode.code: + final generated = CodeGenerator.generate(parsedBody, codeLang); + return SingleChildScrollView( + child: CodeViewer( + generated: generated, + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ), + ); + } + }, + ); } } @@ -4771,63 +5039,61 @@ class _InlineJsonViewState extends ConsumerState<_InlineJsonView> { Widget build(BuildContext context) { final viewMode = ref.watch(bodyViewModeProvider); - dynamic parsed = widget.data; - if (parsed is String) { - try { - parsed = jsonDecode(parsed); - } catch (_) {} - } - - final canToggle = parsed is Map || parsed is List; - final effectiveMode = canToggle ? viewMode : BodyViewMode.json; - // Inline views don't know the device, so Code mode falls back to TS. final codeLang = CodeGenerator.langForPlatform('react_native'); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + return AsyncJsonParser( + rawData: widget.data, + builder: (context, parsed, isJson) { + final canToggle = isJson; + final effectiveMode = canToggle ? viewMode : BodyViewMode.json; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SectionLabel(widget.label), - const Spacer(), - if (canToggle) ...[ - ViewModeSegment( - label: 'Tree', - active: effectiveMode == BodyViewMode.tree, - position: ViewSegmentPosition.start, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.tree), - ), - ViewModeSegment( - label: 'JSON', - active: effectiveMode == BodyViewMode.json, - position: ViewSegmentPosition.middle, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.json), - ), - ViewModeSegment( - label: CodeGenerator.labelFor(codeLang), - active: effectiveMode == BodyViewMode.code, - position: ViewSegmentPosition.end, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.code), - ), - ], + Row( + children: [ + _SectionLabel(widget.label), + const Spacer(), + if (canToggle) ...[ + ViewModeSegment( + label: 'Tree', + active: effectiveMode == BodyViewMode.tree, + position: ViewSegmentPosition.start, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.tree), + ), + ViewModeSegment( + label: 'JSON', + active: effectiveMode == BodyViewMode.json, + position: ViewSegmentPosition.middle, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.json), + ), + ViewModeSegment( + label: CodeGenerator.labelFor(codeLang), + active: effectiveMode == BodyViewMode.code, + position: ViewSegmentPosition.end, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.code), + ), + ], + ], + ), + const SizedBox(height: 8), + _buildInlineContent( + parsed: parsed, + canToggle: canToggle, + mode: effectiveMode, + codeLang: codeLang, + ), ], - ), - const SizedBox(height: 8), - _buildInlineContent( - parsed: parsed, - canToggle: canToggle, - mode: effectiveMode, - codeLang: codeLang, - ), - ], + ); + }, ); } @@ -4837,20 +5103,25 @@ class _InlineJsonViewState extends ConsumerState<_InlineJsonView> { required BodyViewMode mode, required CodeLang codeLang, }) { - if (!canToggle) return JsonPrettyViewer(data: parsed); - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: parsed, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: parsed); - case BodyViewMode.code: - final generated = CodeGenerator.generate(parsed, codeLang); - return CodeViewer( - generated: generated, - lang: codeLang, - languageLabel: CodeGenerator.labelFor(codeLang), - ); - } + if (!canToggle) return JsonPrettyViewer(data: widget.data); + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsed, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: widget.data); + case BodyViewMode.code: + final generated = CodeGenerator.generate(parsed, codeLang); + return CodeViewer( + generated: generated, + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ); + } + }, + ); } } @@ -5371,41 +5642,53 @@ class _StateDetailState extends ConsumerState<_StateDetail> tabs: const ['Diff', 'Previous', 'Next'], ), Expanded( - child: TabBarView( - controller: _tabController, - children: [ - entry.diff.isEmpty - ? EmptyState( - icon: LucideIcons.gitCompare, title: 'No diff') - : ListView.builder( - controller: _diffScrollController, - padding: const EdgeInsets.all(12), - itemCount: entry.diff.length, - itemBuilder: (context, index) => - _DiffRow(diff: entry.diff[index]), - ), - entry.previousState.isEmpty - ? EmptyState( - icon: LucideIcons.layers, - title: 'No previous state') - : _BodyView( - body: entry.previousState, - label: 'Previous State', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, - ), - entry.nextState.isEmpty - ? EmptyState( - icon: LucideIcons.layers, - title: 'No next state') - : _BodyView( - body: entry.nextState, - label: 'Next State', - deviceId: entry.deviceId, - onJsonModeChanged: widget.onJsonModeChanged, - ), - ], - ), + child: TabBarView( + controller: _tabController, + children: [ + LazyTab( + controller: _tabController, + index: 0, + builder: (_) => entry.diff.isEmpty + ? EmptyState( + icon: LucideIcons.gitCompare, title: 'No diff') + : ListView.builder( + controller: _diffScrollController, + padding: const EdgeInsets.all(12), + itemCount: entry.diff.length, + itemBuilder: (context, index) => + _DiffRow(diff: entry.diff[index]), + ), + ), + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => entry.previousState.isEmpty + ? EmptyState( + icon: LucideIcons.layers, + title: 'No previous state') + : _BodyView( + body: entry.previousState, + label: 'Previous State', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), + ), + LazyTab( + controller: _tabController, + index: 2, + builder: (_) => entry.nextState.isEmpty + ? EmptyState( + icon: LucideIcons.layers, + title: 'No next state') + : _BodyView( + body: entry.nextState, + label: 'Next State', + deviceId: entry.deviceId, + onJsonModeChanged: widget.onJsonModeChanged, + ), + ), + ], + ), ), ], ); @@ -5589,31 +5872,64 @@ class _StorageDetailState extends State<_StorageDetail> { final parsedJson = _tryParseJson(entry.value); final isAlreadyJson = entry.value is Map || entry.value is List; final canFormat = parsedJson != null && !isAlreadyJson; + final rawText = entry.value is String + ? entry.value as String + : const JsonEncoder.withIndent(' ').convert(entry.value); + final sizeBytes = rawText.length; + final sizeLabel = AppConstants.formatBytes(sizeBytes); + + // Off-black neutral palette per anti-AI-slop rules — no pure black, + // no purple/blue glows. Subtle tonal hierarchy only. + final surfaceColor = isDark ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA); + final borderColor = isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06); + final labelColor = isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B); + final valueColor = isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A); + final monoStyle = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: valueColor, + ); return SingleChildScrollView( controller: _scrollController, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ── Status row: operation + type + size + actions ── + // Asymmetric per VARIANCE 8: action cluster right-aligned, no + // centered chrome. Mathematically perfect 8px gaps. Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ _TagChip(entry.operation.toUpperCase(), color: opColor), const SizedBox(width: 8), _TagChip(entry.storageType.name, color: ColorTokens.warning), + const SizedBox(width: 8), + _TagChip(sizeLabel, color: Colors.grey), const Spacer(), - _CopyButton( + _IconAction( + icon: LucideIcons.copy, tooltip: 'Copy key', onTap: () => _copyText(context, entry.key, 'Key'), ), ], ), - const SizedBox(height: 16), + + const SizedBox(height: 22), + + // ── Key section: label above value, monospace value ── _SectionLabel('Key'), - const SizedBox(height: 6), + const SizedBox(height: 8), _CodeBlock(text: entry.key, isDark: isDark), + if (entry.value != null) ...[ - const SizedBox(height: 16), + const SizedBox(height: 22), + + // ── Value section ── if (isAlreadyJson) _InlineJsonView(data: entry.value, label: 'Value') else ...[ @@ -5624,15 +5940,15 @@ class _StorageDetailState extends State<_StorageDetail> { if (canFormat) ...[ _FormatToggleButton( isFormatted: _formatted, - onToggle: () => - setState(() { - _formatted = !_formatted; - widget.onFormatChanged?.call(_formatted); - }), + onToggle: () => setState(() { + _formatted = !_formatted; + widget.onFormatChanged?.call(_formatted); + }), ), - const SizedBox(width: 6), + const SizedBox(width: 8), ], - _CopyButton( + _IconAction( + icon: LucideIcons.copy, tooltip: 'Copy value', onTap: () { final text = entry.value is String @@ -5644,17 +5960,995 @@ class _StorageDetailState extends State<_StorageDetail> { ), ], ), - const SizedBox(height: 6), + const SizedBox(height: 8), if (_formatted && parsedJson != null) _InlineJsonView(data: parsedJson, label: '') else _CodeBlock(text: '${entry.value}', isDark: isDark), ], + + const SizedBox(height: 26), + + // ── Metadata divider + key/value list ── + // No card container — just a thin 1px line + tight rows. + // Monospace values per dashboard rules; labels in neutral grey. + Container(height: 1, color: borderColor), + const SizedBox(height: 14), + _MetaRow(label: 'Shape', value: _shapeOf(entry.value), monoStyle: monoStyle, labelColor: labelColor), + _MetaRow(label: 'Length', value: '$sizeBytes chars', monoStyle: monoStyle, labelColor: labelColor), + _MetaRow(label: 'Device', value: entry.deviceId, monoStyle: monoStyle, labelColor: labelColor), + _MetaRow( + label: 'Captured', + value: DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ), + monoStyle: monoStyle, + labelColor: labelColor, + ), ], ], ), ); } + + String _shapeOf(dynamic v) { + if (v == null) return 'null'; + if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; + if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; + if (v is String) { + if (v.isEmpty) return 'String · empty'; + final t = v.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']'))) { + return 'String · JSON-shaped'; + } + return 'String'; + } + return v.runtimeType.toString(); + } +} + +/// Compact key/value row for the storage metadata footer. +class _MetaRow extends StatelessWidget { + final String label; + final String value; + final TextStyle monoStyle; + final Color labelColor; + + const _MetaRow({ + required this.label, + required this.value, + required this.monoStyle, + required this.labelColor, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 84, + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: labelColor, + letterSpacing: 0.3, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: SelectableText( + value, + style: monoStyle, + ), + ), + ], + ), + ); + } +} + +/// Subtle icon button with hover-state feedback. Replaces the hard-edged +/// `_CopyButton` for a calmer, more premium look (MOTION_INTENSITY 6). +class _IconAction extends StatefulWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + const _IconAction({ + required this.icon, + required this.tooltip, + required this.onTap, + }); + + @override + State<_IconAction> createState() => _IconActionState(); +} + +class _IconActionState extends State<_IconAction> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Tooltip( + message: widget.tooltip, + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: const Cubic(0.16, 1, 0.3, 1), + width: 28, + height: 28, + decoration: BoxDecoration( + color: _hovered + ? (isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05)) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + widget.icon, + size: 13, + color: isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B), + ), + ), + ), + ), + ); + } +} + +/// Metadata footer for the storage detail view. Renders a border-top divider +/// followed by a clean key/value list — no card containers, just a thin line +/// and tight monospace rows, per the dashboard-hardening design rule. +class _StorageMetadataSection extends StatelessWidget { + final StorageEntry entry; + final bool isDark; + + const _StorageMetadataSection({required this.entry, required this.isDark}); + + String _shape() { + final v = entry.value; + if (v == null) return 'null'; + if (v is Map) return 'Map · ${v.length} keys'; + if (v is List) return 'List · ${v.length} items'; + if (v is String) { + if (v.isEmpty) return 'String · empty'; + final t = v.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']'))) { + return 'String · JSON'; + } + return 'String · ${v.length} chars'; + } + return v.runtimeType.toString(); + } + + @override + Widget build(BuildContext context) { + final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; + final valueColor = isDark ? const Color(0xFFD4D4D4) : const Color(0xFF1F2328); + final dividerColor = isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06); + final monoStyle = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: valueColor, + ); + + Widget row(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: labelColor, + letterSpacing: 0.2, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + value, + style: monoStyle, + softWrap: true, + ), + ), + ], + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 1, color: dividerColor), + const SizedBox(height: 14), + row('Type', entry.storageType.name), + row('Operation', entry.operation), + row('Shape', _shape()), + row('Device', entry.deviceId), + row('Timestamp', + DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + )), + ], + ); + } +} + +// ═══════════════════════════════════════════════ +// Storage Detail (Redesigned) +// ═══════════════════════════════════════════════ + +class _StorageDetailRedesign extends ConsumerStatefulWidget { + final StorageEntry entry; + final VoidCallback? onClose; + const _StorageDetailRedesign({ + required this.entry, + this.onClose, + }); + + @override + ConsumerState<_StorageDetailRedesign> createState() => + _StorageDetailRedesignState(); +} + +class _StorageDetailRedesignState + extends ConsumerState<_StorageDetailRedesign> { + final _scrollController = SmoothScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + // ── Design tokens ───────────────────────────────────────────── + // Off-black neutrals (anti-pure-black rule). One accent reserved + // for the operation chip — everything else is desaturated. + Color _textPrimary(bool isDark) => + isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A); + Color _textSecondary(bool isDark) => + isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B); + Color _divider(bool isDark) => isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06); + + Color _opColor() { + switch (widget.entry.operation.toLowerCase()) { + case 'write': + return const Color(0xFF34D399); // emerald 400 — single accent + case 'read': + return const Color(0xFF60A5FA); // blue 400 + case 'delete': + case 'clear': + return const Color(0xFFF87171); // red 400 + default: + return const Color(0xFFFBBF24); // amber 400 + } + } + + String _shapeOf(dynamic v) { + if (v == null) return 'null'; + if (v is Map) return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; + if (v is List) return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; + if (v is String) { + if (v.isEmpty) return 'String · empty'; + final t = v.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']'))) { + return 'String · JSON-shaped'; + } + return 'String'; + } + return v.runtimeType.toString(); + } + + dynamic _parsedJson() { + final v = widget.entry.value; + if (v is! String) return null; + try { + final p = jsonDecode(v); + if (p is Map || p is List) return p; + } catch (_) {} + return null; + } + + dynamic _displayValue() { + final v = widget.entry.value; + if (v is Map || v is List) return v; + return _parsedJson() ?? v; + } + + bool get _isJsonLike { + final v = widget.entry.value; + if (v is Map || v is List) return true; + return _parsedJson() != null; + } + + String _sizeLabel() { + final raw = widget.entry.value is String + ? widget.entry.value as String + : const JsonEncoder.withIndent(' ').convert(widget.entry.value); + return AppConstants.formatBytes(raw.length); + } + + String _captureText() { + final v = widget.entry.value; + return v is String ? v : const JsonEncoder.withIndent(' ').convert(v); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final mode = ref.watch(bodyViewModeProvider); + final devices = ref.watch(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == widget.entry.deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); + + final monoPrimary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + height: 1.5, + color: _textPrimary(isDark), + ); + final monoSecondary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + height: 1.5, + color: _textSecondary(isDark), + ); + + return SingleChildScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ────────────────────────────────────────────────────────── + // 1) HEADER — operation accent + storage type + key chip + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _OpBadge(label: widget.entry.operation, color: _opColor()), + const SizedBox(width: 8), + _TypeBadge(label: widget.entry.storageType.name), + const Spacer(), + _HeaderIconButton( + icon: LucideIcons.copy, + tooltip: S.of(context).copyKey, + isDark: isDark, + onTap: () => _copyText(context, widget.entry.key, 'Key'), + ), + const SizedBox(width: 4), + _HeaderIconButton( + icon: LucideIcons.camera, + tooltip: _isJsonLike + ? S.of(context).captureDataJson + : S.of(context).captureDataText, + isDark: isDark, + onTap: () => + _captureData(isDark, devices, codeLang, codeLabel), + ), + const SizedBox(width: 4), + _HeaderIconButton( + icon: LucideIcons.x, + tooltip: S.of(context).close, + isDark: isDark, + onTap: () => widget.onClose?.call(), + ), + ], + ), + ), + + // ────────────────────────────────────────────────────────── + // 2) KEY DISPLAY — large monospace, hero element + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _Label(text: 'KEY', isDark: isDark), + const Spacer(), + _HeaderIconButton( + icon: LucideIcons.copy, + tooltip: 'Copy key', + isDark: isDark, + onTap: () => _copyText(context, widget.entry.key, 'Key'), + ), + ], + ), + const SizedBox(height: 8), + SelectableText( + widget.entry.key, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 15, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: _textPrimary(isDark), + height: 1.4, + ), + ), + ], + ), + ), + + // ────────────────────────────────────────────────────────── + // 3) METADATA GRID — 2x2 bento layout + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Label(text: 'METADATA', isDark: isDark), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _MetaCell( + label: 'SHAPE', + value: _shapeOf(widget.entry.value), + valueStyle: monoPrimary, + isDark: isDark, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetaCell( + label: 'SIZE', + value: _sizeLabel(), + valueStyle: monoPrimary, + isDark: isDark, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _MetaCell( + label: 'DEVICE', + value: widget.entry.deviceId, + valueStyle: monoSecondary, + isDark: isDark, + monospace: true, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetaCell( + label: 'CAPTURED', + value: DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch( + widget.entry.timestamp), + ), + valueStyle: monoPrimary, + isDark: isDark, + ), + ), + ], + ), + ], + ), + ), + + // ────────────────────────────────────────────────────────── + // 4) DIVIDER — separates data zones + // ────────────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + child: Container(height: 1, color: _divider(isDark)), + ), + + // ────────────────────────────────────────────────────────── + // 5) VALUE SECTION — switcher + content + // ────────────────────────────────────────────────────────── + if (widget.entry.value != null && _isJsonLike) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: SizedBox( + width: double.infinity, + child: ViewModeSwitcher( + current: mode, + codeLabel: codeLabel, + onChanged: (m) => + ref.read(bodyViewModeProvider.notifier).set(m), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: _buildValueContent( + isDark: isDark, + mode: mode, + codeLang: codeLang, + codeLabel: codeLabel, + ), + ), + ] else if (widget.entry.value != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), + child: Row( + children: [ + _Label(text: 'VALUE', isDark: isDark), + const Spacer(), + _HeaderIconButton( + icon: LucideIcons.copy, + tooltip: 'Copy value', + isDark: isDark, + onTap: () => _copyText(context, _captureText(), 'Value'), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 28), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.025), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _divider(isDark)), + ), + child: SelectableText( + widget.entry.value.toString(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: _textPrimary(isDark), + ), + ), + ), + ), + ] else ...[ + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 28), + child: _EmptyValue(isDark: isDark), + ), + ], + ], + ), + ); + } + + Widget _buildValueContent({ + required bool isDark, + required BodyViewMode mode, + required CodeLang codeLang, + required String codeLabel, + }) { + final value = _displayValue(); + + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: JsonViewer(data: value, initiallyExpanded: true), + ); + case BodyViewMode.json: + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: JsonPrettyViewer(data: value), + ); + case BodyViewMode.code: + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: CodeViewer( + generated: CodeGenerator.generate(value, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ), + ); + } + }, + ); + } + + void _copyText(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '$label copied'); + } + + // ── Screenshot: data only (KEY + VALUE in current view mode) ── + void _captureData( + bool isDark, + List devices, + CodeLang codeLang, + String codeLabel, + ) { + final value = _displayValue(); + final monoKey = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: _textPrimary(isDark), + ); + final labelStyle = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: isDark ? const Color(0xFF6B6B6B) : const Color(0xFF8B8B8B), + ); + final divider = _divider(isDark); + final mode = ref.read(bodyViewModeProvider); + + // Value widget: respect 3-mode only when the payload is JSON-like. + // Plain text/number/bool capture as raw text — no switcher chrome. + final Widget valueWidget; + if (_isJsonLike) { + valueWidget = switch (mode) { + BodyViewMode.tree => JsonViewer(data: value, initiallyExpanded: true), + BodyViewMode.json => JsonPrettyViewer(data: value), + BodyViewMode.code => CodeViewer( + generated: CodeGenerator.generate(value, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ), + }; + } else if (widget.entry.value == null) { + valueWidget = const SizedBox.shrink(); + } else { + valueWidget = Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.03) + : Colors.black.withValues(alpha: 0.025), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: divider), + ), + child: SelectableText( + widget.entry.value.toString(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: _textPrimary(isDark), + ), + ), + ); + } + + // Header style matches _storageScreenshot for visual consistency + // between full and data captures. + final capturedAt = DateTime.now().toIso8601String().split('.').first; + final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; + + final capture = Container( + color: isDark ? const Color(0xFF121212) : const Color(0xFFFAFAFA), + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header context (matches _storageScreenshot) ── + Row( + children: [ + Icon(LucideIcons.database, + size: 14, color: ColorTokens.warning), + const SizedBox(width: 6), + Text( + 'Storage Detail', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + color: labelColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '· ${widget.entry.storageType.name.toUpperCase()} · $capturedAt', + style: TextStyle( + fontSize: 10, + color: labelColor, + letterSpacing: 0.3, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + // ── Badges (mirror _StorageDetailRedesign header row) ── + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _OpBadge(label: widget.entry.operation, color: _opColor()), + const SizedBox(width: 8), + _TypeBadge(label: widget.entry.storageType.name), + ], + ), + const SizedBox(height: 18), + Container(height: 1, color: divider), + const SizedBox(height: 18), + Text('KEY', style: labelStyle), + const SizedBox(height: 8), + SelectableText(widget.entry.key, style: monoKey), + const SizedBox(height: 18), + Container(height: 1, color: divider), + const SizedBox(height: 18), + Text('VALUE', style: labelStyle), + const SizedBox(height: 10), + valueWidget, + ], + ), + ); + + captureWidgetAsImage( + context, + capture, + fileName: _buildScreenshotName(devices, '_data'), + onSaved: (path) { + if (mounted) showScreenshotSavedToast(context, filePath: path); + }, + ); + } + + String _buildScreenshotName(List devices, String suffix) { + final entry = widget.entry; + // Note: appName intentionally omitted to avoid leaking the app + // identifier into filenames shared with clients. + return buildRichScreenshotName( + type: entry.storageType.name, + subject: entry.key, + suffix: suffix, + ); + } +} + +// ── Helper widgets for the redesign ─────────────────────────── + +class _Label extends StatelessWidget { + final String text; + final bool isDark; + const _Label({required this.text, required this.isDark}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: isDark ? const Color(0xFF6B6B6B) : const Color(0xFF8B8B8B), + ), + ); + } +} + +class _OpBadge extends StatelessWidget { + final String label; + final Color color; + const _OpBadge({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.28), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label.toUpperCase(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w700, + color: color, + letterSpacing: 0.6, + ), + ), + ], + ), + ); + } +} + +class _TypeBadge extends StatelessWidget { + final String label; + const _TypeBadge({required this.label}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fg = isDark ? const Color(0xFFB0B0B0) : const Color(0xFF4A4A4A); + final border = isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.08); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: border, width: 1), + ), + child: Text( + label, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w600, + color: fg, + letterSpacing: 0.3, + ), + ), + ); + } +} + +class _HeaderIconButton extends StatefulWidget { + final IconData icon; + final String tooltip; + final bool isDark; + final VoidCallback onTap; + + const _HeaderIconButton({ + required this.icon, + required this.tooltip, + required this.isDark, + required this.onTap, + }); + + @override + State<_HeaderIconButton> createState() => _HeaderIconButtonState(); +} + +class _HeaderIconButtonState extends State<_HeaderIconButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final hoverColor = widget.isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.05); + final iconColor = widget.isDark + ? const Color(0xFF9A9A9A) + : const Color(0xFF6B6B6B); + return Tooltip( + message: widget.tooltip, + child: MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: const Cubic(0.16, 1, 0.3, 1), + width: 30, + height: 30, + decoration: BoxDecoration( + color: _hovered ? hoverColor : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Icon(widget.icon, size: 14, color: iconColor), + ), + ), + ), + ); + } +} + +class _MetaCell extends StatelessWidget { + final String label; + final String value; + final TextStyle valueStyle; + final bool isDark; + final bool monospace; + + const _MetaCell({ + required this.label, + required this.value, + required this.valueStyle, + required this.isDark, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Label(text: label, isDark: isDark), + const SizedBox(height: 6), + SelectableText( + value, + style: valueStyle, + maxLines: 1, + ), + ], + ); + } +} + +class _EmptyValue extends StatelessWidget { + final bool isDark; + const _EmptyValue({required this.isDark}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16), + alignment: Alignment.center, + child: Column( + children: [ + Icon( + LucideIcons.database, + size: 24, + color: isDark ? const Color(0xFF4A4A4A) : const Color(0xFFB0B0B0), + ), + const SizedBox(height: 10), + Text( + 'No value stored', + style: TextStyle( + fontSize: 13, + color: isDark ? const Color(0xFF8B8B8B) : const Color(0xFF6B6B6B), + ), + ), + ], + ), + ); + } } // ═══════════════════════════════════════════════ diff --git a/lib/features/all_events/provider/all_events_provider.dart b/lib/features/all_events/provider/all_events_provider.dart index 7fd414a..84bd7cf 100644 --- a/lib/features/all_events/provider/all_events_provider.dart +++ b/lib/features/all_events/provider/all_events_provider.dart @@ -3,8 +3,10 @@ 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 '../../../core/utils/network_url_utils.dart'; import '../../../models/display/display_entry.dart'; import '../../../models/log/error_event.dart'; +import '../../../models/network/network_entry.dart'; import '../../../server/providers/server_providers.dart'; import '../../console/provider/console_providers.dart'; import '../../display/provider/display_providers.dart'; @@ -106,7 +108,7 @@ final allEventsProvider = Provider>((ref) { id: req.id, deviceId: req.deviceId, timestamp: req.startTime, - title: '${req.method} ${_shortenUrl(req.url)}', + title: _networkTitle(req), subtitle: req.isComplete ? '${req.statusCode} - ${formatDuration(req.duration ?? 0)}' : 'in progress', @@ -257,10 +259,21 @@ final filteredAllEventsProvider = Provider>((ref) { }); String _shortenUrl(String url) { + if (isMalformedNetworkUrl(url)) return ''; try { final uri = Uri.parse(url); - return uri.path; + if (uri.path.isNotEmpty) return uri.path; + if (uri.host.isNotEmpty) return uri.host; + return url; } catch (_) { return url; } } + +String _networkTitle(NetworkEntry req) { + if (req.serviceAction != null) { + final path = Uri.tryParse(req.url)?.path ?? ''; + if (path.isEmpty || path == '/') return '${req.method} ${req.serviceAction}'; + } + return '${req.method} ${_shortenUrl(req.url)}'; +} diff --git a/lib/features/console/presentation/pages/console_page.dart b/lib/features/console/presentation/pages/console_page.dart index 6746550..856005f 100644 --- a/lib/features/console/presentation/pages/console_page.dart +++ b/lib/features/console/presentation/pages/console_page.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import '../../../../l10n/app_localizations.dart'; import 'package:flutter/services.dart'; @@ -11,7 +9,6 @@ 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'; - import '../../../../components/inputs/search_field.dart'; import '../../../../components/lists/stable_list_view.dart'; import '../../../../components/misc/status_badge.dart'; @@ -19,9 +16,11 @@ import '../../../../components/misc/jump_to_latest_fab.dart'; import '../../../../components/viewers/json_viewer.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; +import '../../../../core/utils/code_generator.dart'; +import '../../../../server/providers/server_providers.dart'; import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../core/utils/screenshot_filename.dart'; import '../../../../models/log/log_entry.dart'; -import '../../../../server/providers/server_providers.dart'; import '../../../../core/utils/toast_utils.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; import '../../provider/console_providers.dart'; @@ -181,6 +180,13 @@ class _ConsolePageState extends ConsumerState { DateTime.fromMillisecondsSinceEpoch(entry.timestamp), ); + // Build a descriptive filename: log___full.png + final fileName = buildRichScreenshotName( + type: 'log', + subject: entry.tag ?? entry.level.name, + suffix: '_full', + ); + captureWidgetAsImage( context, Container( @@ -317,6 +323,7 @@ class _ConsolePageState extends ConsumerState { ), ), width: 600, + fileName: fileName, ); } @@ -1078,7 +1085,10 @@ class _LogDetailPanelState extends State<_LogDetailPanel> { // pattern as the All Events detail panel. _SectionLabel(label: S.of(context).message), const SizedBox(height: 6), - _LogMessageBlock(message: entry.message, isDark: isDark), + _LogMessageBlock( + message: entry.message, + deviceId: entry.deviceId, + ), // Metadata if (entry.metadata != null && @@ -1086,22 +1096,9 @@ class _LogDetailPanelState extends State<_LogDetailPanel> { const SizedBox(height: 20), _SectionLabel(label: S.of(context).metadata), 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: JsonViewer(data: entry.metadata), + _MetadataBlock( + data: entry.metadata!, + deviceId: entry.deviceId, ), ], @@ -1142,6 +1139,84 @@ class _LogDetailPanelState extends State<_LogDetailPanel> { } } +// --------------------------------------------------------------------------- +// Metadata block — same 3-mode toggle as the message block, so the user can +// flip between Tree / JSON / Code without leaving the panel. +// --------------------------------------------------------------------------- + +class _MetadataBlock extends ConsumerWidget { + final Map data; + final String deviceId; + + const _MetadataBlock({ + required this.data, + required this.deviceId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final mode = ref.watch(metadataViewModeProvider); + final devices = ref.watch(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); + + return 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SizedBox( + width: double.infinity, + child: ViewModeSwitcher( + current: mode, + codeLabel: codeLabel, + onChanged: (BodyViewMode m) => + ref.read(metadataViewModeProvider.notifier).state = m, + ), + ), + ), + DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: data, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: data); + case BodyViewMode.code: + return CodeViewer( + generated: CodeGenerator.generate(data, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ); + } + }, + ), + ], + ), + ); + } +} + // --------------------------------------------------------------------------- // Section label used in the detail panel // --------------------------------------------------------------------------- @@ -1168,97 +1243,112 @@ 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 { +class _LogMessageBlock extends ConsumerWidget { final String message; - final bool isDark; + final String deviceId; - 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; + const _LogMessageBlock({ + required this.message, + required this.deviceId, + }); @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; - } + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final mode = ref.watch(bodyViewModeProvider); + + final devices = ref.watch(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + + return AsyncJsonParser( + rawData: message, + builder: (context, parsed, isJson) { + // Plain text: skip the 3-mode toggle entirely. The user gets the + // raw message without any view-mode chrome. + if (!isJson) { + 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, + ), + ), + padding: const EdgeInsets.all(12), + child: _PlainMessageBlock(text: message, isDark: isDark), + ); + } - 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), + final body = DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsed, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: parsed); + case BodyViewMode.code: + return CodeViewer( + generated: CodeGenerator.generate(parsed, codeLang), + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ); + } + }, + ); + + 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, ), ), - Padding( - padding: const EdgeInsets.all(12), - child: body, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: ViewModeSwitcher( + current: mode, + codeLabel: CodeGenerator.labelFor(codeLang), + onChanged: (BodyViewMode m) => + ref.read(bodyViewModeProvider.notifier).set(m), + ), + ), + 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 { +class _PlainMessageBlock extends StatelessWidget { final String text; final bool isDark; - const _CodeBlock({required this.text, required this.isDark}); + const _PlainMessageBlock({required this.text, required this.isDark}); @override Widget build(BuildContext context) { @@ -1267,8 +1357,10 @@ class _CodeBlock extends StatelessWidget { style: TextStyle( fontFamily: AppConstants.monoFontFamily, fontSize: 12, - color: isDark ? ColorTokens.lightBackground : ColorTokens.darkNeutral, - height: 1.6, + height: 1.5, + color: isDark + ? const Color(0xFFCCCCCC) + : const Color(0xFF333333), ), ); } 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 bfbddba..21a53db 100644 --- a/lib/features/error_inspector/presentation/pages/error_inspector_page.dart +++ b/lib/features/error_inspector/presentation/pages/error_inspector_page.dart @@ -12,7 +12,10 @@ import '../../../../components/lists/stable_list_view.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../core/utils/screenshot_filename.dart'; +import '../../../../components/viewers/json_viewer.dart'; import '../../../../core/utils/toast_utils.dart'; +import '../../../../server/providers/server_providers.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; import '../../../../models/log/error_event.dart'; import '../../provider/error_providers.dart'; @@ -189,6 +192,13 @@ class _ErrorInspectorPageState extends ConsumerState { DateTime.fromMillisecondsSinceEpoch(entry.timestamp), ); + // Resolve a descriptive file name: error___full.png + final fileName = buildRichScreenshotName( + type: 'error', + subject: entry.source ?? entry.severity.name, + suffix: '_full', + ); + captureWidgetAsImage( context, Container( @@ -266,6 +276,7 @@ class _ErrorInspectorPageState extends ConsumerState { ], ), ), + fileName: fileName, ); } @@ -1766,84 +1777,96 @@ class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> ), // Tab content Expanded( - child: TabBarView( - controller: _tabController, - children: [ - // Message tab - SingleChildScrollView( - controller: _messageScrollController, - padding: const EdgeInsets.all(16), - child: TextComponent( - entry.message, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 13, - color: isDark ? Colors.white : Colors.black87, - ), - ), - ), - // Stack trace tab - entry.stackTrace != null - ? SingleChildScrollView( - controller: _stackTraceScrollController, - padding: const EdgeInsets.all(16), - child: TextComponent( - entry.stackTrace!, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black87, - ), + child: TabBarView( + controller: _tabController, + children: [ + // Message tab + LazyTab( + controller: _tabController, + index: 0, + builder: (_) => SingleChildScrollView( + controller: _messageScrollController, + padding: const EdgeInsets.all(16), + child: TextComponent( + entry.message, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + color: isDark ? Colors.white : Colors.black87, ), - ) - : Center( - child: TextComponent(S.of(context).noStackTrace), ), - // Details tab - SingleChildScrollView( - controller: _detailsScrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _DetailRow(label: S.of(context).platform, value: entry.platform.name), - _DetailRow(label: S.of(context).severity, value: entry.severity.name), - _DetailRow(label: S.of(context).source, value: entry.source ?? 'unknown'), - _DetailRow(label: S.of(context).deviceId, value: entry.deviceId), - _DetailRow(label: S.of(context).deviceInfo, value: entry.deviceInfo ?? 'unknown'), - if (entry.metadata != null) ...[ - const SizedBox(height: 12), - TextComponent( - 'Metadata', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey[500], - ), - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isDark ? Colors.black26 : Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: TextComponent( - entry.metadata.toString(), - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black87, + ), + ), + // Stack trace tab + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => entry.stackTrace != null + ? SingleChildScrollView( + controller: _stackTraceScrollController, + padding: const EdgeInsets.all(16), + child: TextComponent( + entry.stackTrace!, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white70 : Colors.black87, + ), ), + ) + : Center( + child: TextComponent(S.of(context).noStackTrace), ), - ), - ], - ], ), - ), - ], - ), + // Details tab + LazyTab( + controller: _tabController, + index: 2, + builder: (_) => SingleChildScrollView( + controller: _detailsScrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(label: S.of(context).platform, value: entry.platform.name), + _DetailRow(label: S.of(context).severity, value: entry.severity.name), + _DetailRow(label: S.of(context).source, value: entry.source ?? 'unknown'), + _DetailRow(label: S.of(context).deviceId, value: entry.deviceId), + _DetailRow(label: S.of(context).deviceInfo, value: entry.deviceInfo ?? 'unknown'), + if (entry.metadata != null) ...[ + const SizedBox(height: 12), + TextComponent( + 'Metadata', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.grey[500], + ), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? Colors.black26 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: TextComponent( + entry.metadata.toString(), + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white70 : Colors.black87, + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), ), ], ); @@ -1853,17 +1876,33 @@ class _ErrorDetailPanelState extends ConsumerState<_ErrorDetailPanel> Future _takeFullScreenshot() async { final isDark = Theme.of(context).brightness == Brightness.dark; + final entry = widget.entry; + final subject = entry.source ?? entry.severity.name; + final fileName = buildRichScreenshotName( + type: 'error', + subject: subject, + suffix: '_full', + ); await captureWidgetAsImage( context, _buildFullScreenshotWidget(isDark), + fileName: fileName, ); } Future _takeTabScreenshot() async { final isDark = Theme.of(context).brightness == Brightness.dark; + final entry = widget.entry; + final subject = entry.source ?? entry.severity.name; + final fileName = buildRichScreenshotName( + type: 'error', + subject: subject, + suffix: '_tab', + ); await captureWidgetAsImage( context, _buildTabScreenshotWidget(isDark, _tabController.index), + fileName: fileName, ); } 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 0b4523e..747f782 100644 --- a/lib/features/network_inspector/presentation/pages/network_inspector_page.dart +++ b/lib/features/network_inspector/presentation/pages/network_inspector_page.dart @@ -9,6 +9,7 @@ import 'package:flutter/rendering.dart' hide ScrollDirection; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/duration_format.dart'; +import '../../../../core/utils/screenshot_filename.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -446,6 +447,36 @@ class _Toolbar extends ConsumerWidget { : Colors.black.withValues(alpha: 0.08), ), const SizedBox(width: 2), + // ── Clear stale (only when there are pending > 10min) ── + Consumer( + builder: (context, ref, _) { + final staleCount = + ref.watch(staleNetworkCountProvider); + if (staleCount == 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(right: 2), + child: _ClearStaleBtn( + count: staleCount, + onTap: () { + final removed = ref + .read(networkEntriesProvider.notifier) + .clearStale(); + if (removed > 0 && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + 'Cleared $removed stale request${removed == 1 ? '' : 's'} ' + '(pending > 10min)', + ), + ), + ); + } + }, + ), + ); + }, + ), _IconBtn( icon: LucideIcons.trash2, tooltip: S.of(context).clear, @@ -650,10 +681,126 @@ class _IconBtnState extends State<_IconBtn> { } } +/// "Clear stale (N)" pill that surfaces pending requests that haven't +/// received a response in over 10 minutes — the client likely crashed +/// before completing them. Tinted amber so it stands out from the +/// neutral toolbar without screaming like the red trash button. +class _ClearStaleBtn extends StatefulWidget { + final int count; + final VoidCallback onTap; + + const _ClearStaleBtn({required this.count, required this.onTap}); + + @override + State<_ClearStaleBtn> createState() => _ClearStaleBtnState(); +} + +class _ClearStaleBtnState extends State<_ClearStaleBtn> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final loc = S.of(context); + const accent = Color(0xFFFBBF24); // amber 400 — matches Tree mode + + final bg = _hovered + ? accent.withValues(alpha: isDark ? 0.18 : 0.16) + : accent.withValues(alpha: isDark ? 0.12 : 0.10); + final border = accent.withValues(alpha: isDark ? 0.40 : 0.36); + + return Tooltip( + message: loc.clearStaleTooltip(widget.count), + child: GestureDetector( + onTap: widget.onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(7), + border: Border.all(color: border, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.timerOff, size: 12, color: accent), + const SizedBox(width: 5), + Text( + loc.clearStaleButton(widget.count), + style: TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + color: accent, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + // --------------------------------------------------------------------------- // Request card tile // --------------------------------------------------------------------------- +class _ServiceTag extends StatelessWidget { + final String name; + const _ServiceTag({required this.name}); + + @override + Widget build(BuildContext context) { + final color = _colorForService(name); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + name, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w700, + color: color, + letterSpacing: 0.3, + ), + ), + ); + } + + static Color colorForService(String name) { + switch (name) { + case 'AWS': + case 'AWS Cognito': + return const Color(0xFFFF9900); + case 'Google Maps': + return const Color(0xFF4285F4); + case 'Firebase': + return const Color(0xFFFFCA28); + case 'Stripe': + return const Color(0xFF635BFF); + case 'GitHub': + return const Color(0xFF8B949E); + case 'Sentry': + return const Color(0xFF6C5FC7); + default: + return ColorTokens.primary; + } + } + + Color _colorForService(String n) => colorForService(n); +} + class _RequestCard extends ConsumerWidget { final NetworkEntry entry; final bool isSelected; @@ -685,6 +832,10 @@ class _RequestCard extends ConsumerWidget { } catch (_) {} final displayUrl = uri?.path ?? entry.url; final host = uri?.host ?? ''; + final isRootPath = displayUrl == '/' || displayUrl.isEmpty; + final titleText = (entry.serviceAction != null && isRootPath) + ? entry.serviceAction! + : displayUrl; // Left bar color based on status code final Color leftBarColor; @@ -783,7 +934,7 @@ class _RequestCard extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - displayUrl, + titleText, style: TextStyle( fontFamily: AppConstants.monoFontFamily, fontSize: 12, @@ -800,6 +951,10 @@ class _RequestCard extends ConsumerWidget { const SizedBox(height: 2), Row( children: [ + if (entry.serviceName != null) ...[ + _ServiceTag(name: entry.serviceName!), + const SizedBox(width: 4), + ], // Source badge Container( padding: const EdgeInsets.symmetric( @@ -1252,18 +1407,34 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> child: TabBarView( controller: _tabController, children: [ - _HeadersTab(entry: entry), - _BodyTab( - body: entry.requestBody, - label: 'Request', - deviceId: entry.deviceId, + LazyTab( + controller: _tabController, + index: 0, + builder: (_) => _HeadersTab(entry: entry), ), - _BodyTab( - body: entry.responseBody, - label: 'Response', - deviceId: entry.deviceId, + LazyTab( + controller: _tabController, + index: 1, + builder: (_) => _BodyTab( + body: entry.requestBody, + label: 'Request', + deviceId: entry.deviceId, + ), + ), + LazyTab( + controller: _tabController, + index: 2, + builder: (_) => _BodyTab( + body: entry.responseBody, + label: 'Response', + deviceId: entry.deviceId, + ), + ), + LazyTab( + controller: _tabController, + index: 3, + builder: (_) => _TimingTab(entry: entry), ), - _TimingTab(entry: entry), ], ), ), @@ -1274,7 +1445,7 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> // ---- Screenshot ---- - Future _captureAndSave(Widget screenshotWidget) async { + Future _captureAndSave(Widget screenshotWidget, {String? fileName}) async { try { _showCaptureFlash(); @@ -1319,10 +1490,13 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> if (byteData == null) return; final pngBytes = byteData.buffer.asUint8List(); - final fileName = - 'devconnect_network_${DateTime.now().millisecondsSinceEpoch}.png'; + final baseName = (fileName == null || fileName.isEmpty) + ? 'devconnect_network_${DateTime.now().millisecondsSinceEpoch}' + : fileName; + final outName = + baseName.endsWith('.png') ? baseName : '$baseName.png'; final location = await getSaveLocation( - suggestedName: fileName, + suggestedName: outName, acceptedTypeGroups: [ const XTypeGroup(label: 'PNG Image', extensions: ['png']), ], @@ -1598,14 +1772,37 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> Future _takeFullScreenshot() async { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - await _captureAndSave(_buildFullScreenshotWidget(isDark)); + final subject = _urlPath(widget.entry.url); + final fileName = buildRichScreenshotName( + type: 'network', + subject: subject, + suffix: '_full', + ); + await _captureAndSave(_buildFullScreenshotWidget(isDark), + fileName: fileName); } Future _takeTabScreenshot() async { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; + final subject = _urlPath(widget.entry.url); + final fileName = buildRichScreenshotName( + type: 'network', + subject: subject, + suffix: '_tab', + ); await _captureAndSave( - _buildTabScreenshotWidget(isDark, _tabController.index)); + _buildTabScreenshotWidget(isDark, _tabController.index), + fileName: fileName); + } + + String _urlPath(String url) { + try { + final p = Uri.parse(url).path; + return p.isEmpty ? url : p; + } catch (_) { + return url; + } } Widget _buildFullScreenshotWidget(bool isDark) { @@ -1618,10 +1815,12 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> if (parsedReqBody is String) { try { parsedReqBody = jsonDecode(parsedReqBody); } catch (_) {} } + final reqIsBlob = _isBlobPayload(parsedReqBody); dynamic parsedResBody = entry.responseBody; if (parsedResBody is String) { try { parsedResBody = jsonDecode(parsedResBody); } catch (_) {} } + final resIsBlob = _isBlobPayload(parsedResBody); return Container( color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, @@ -1685,9 +1884,11 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> _screenshotSection('Request Body', isDark), Padding( padding: const EdgeInsets.all(12), - child: parsedReqBody is Map || parsedReqBody is List - ? JsonViewer(data: parsedReqBody, initiallyExpanded: true) - : JsonPrettyViewer(data: parsedReqBody), + child: reqIsBlob.$1 != null + ? _screenshotBlobNote(reqIsBlob, isDark) + : parsedReqBody is Map || parsedReqBody is List + ? JsonViewer(data: parsedReqBody, initiallyExpanded: true) + : JsonPrettyViewer(data: parsedReqBody), ), ], // Response body @@ -1695,9 +1896,11 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> _screenshotSection('Response Body', isDark), Padding( padding: const EdgeInsets.all(12), - child: parsedResBody is Map || parsedResBody is List - ? JsonViewer(data: parsedResBody, initiallyExpanded: true) - : JsonPrettyViewer(data: parsedResBody), + child: resIsBlob.$1 != null + ? _screenshotBlobNote(resIsBlob, isDark) + : parsedResBody is Map || parsedResBody is List + ? JsonViewer(data: parsedResBody, initiallyExpanded: true) + : JsonPrettyViewer(data: parsedResBody), ), ], ], @@ -1860,18 +2063,23 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> Widget _buildBodyContent( dynamic parsed, bool canToggle, BodyViewMode mode, CodeLang codeLang) { if (!canToggle) return JsonPrettyViewer(data: parsed); - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: parsed, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: parsed); - case BodyViewMode.code: - return CodeViewer( - generated: CodeGenerator.generate(parsed, codeLang), - lang: codeLang, - languageLabel: CodeGenerator.labelFor(codeLang), - ); - } + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsed, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: parsed); + case BodyViewMode.code: + return CodeViewer( + generated: CodeGenerator.generate(parsed, codeLang), + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ); + } + }, + ); } Widget _screenshotSection(String title, bool isDark) { @@ -1935,6 +2143,38 @@ class _RequestDetailPanelState extends ConsumerState<_RequestDetailPanel> } return buf.toString(); } + + (String?, int?) _isBlobPayload(dynamic body) { + if (body is String) { + final t = body.trim(); + final m = RegExp(r'^<\s*(blob|arraybuffer)\s+(\d+)\s*bytes\s*>\s*$', + caseSensitive: false) + .firstMatch(t); + if (m != null) return (m.group(1), int.tryParse(m.group(2)!)); + final m2 = RegExp(r'^\s*$', caseSensitive: false) + .firstMatch(t); + if (m2 != null) return ('blob', int.tryParse(m2.group(1)!)); + final m3 = RegExp(r'^(\d+)\s*bytes$', caseSensitive: false).firstMatch(t); + if (m3 != null) return ('blob', int.tryParse(m3.group(1)!)); + } + return (null, null); + } + + Widget _screenshotBlobNote((String?, int?) blob, bool isDark) { + final type = blob.$1 ?? 'blob'; + final bytes = blob.$2 ?? 0; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: TextComponent( + '$type payload ($bytes bytes) — binary, cannot be inspected.\nIdentify the action via the X-Amz-Target header.', + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + ); + } } // --------------------------------------------------------------------------- @@ -2374,6 +2614,66 @@ class _HeaderRowWithCopyState extends State<_HeaderRowWithCopy> { // Body tab (request / response) // --------------------------------------------------------------------------- +class _BlobInfo extends StatelessWidget { + final String label; + final int sizeBytes; + final bool isDark; + + const _BlobInfo({ + required this.label, + required this.sizeBytes, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.package, + size: 28, color: isDark ? Colors.white38 : Colors.black38), + const SizedBox(height: 12), + Text( + S.of(context).binaryBody(label), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isDark + ? ColorTokens.lightBackground + : ColorTokens.darkNeutral, + ), + ), + const SizedBox(height: 6), + Text( + S.of(context).binaryBodySize( + AppConstants.formatBytes(sizeBytes), + sizeBytes, + ), + style: TextStyle( + fontSize: 12, + color: isDark ? Colors.white54 : Colors.black54, + fontFamily: AppConstants.monoFontFamily, + ), + ), + const SizedBox(height: 8), + Text( + S.of(context).binaryBodyHint, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: isDark ? Colors.white38 : Colors.black45, + ), + ), + ], + ), + ), + ); + } +} + class _BodyTab extends ConsumerStatefulWidget { final dynamic body; final String label; @@ -2411,21 +2711,15 @@ class _BodyTabState extends ConsumerState<_BodyTab> { ); } - // Try to parse string body as JSON - dynamic parsedBody = widget.body; - if (parsedBody is String) { - try { - parsedBody = jsonDecode(parsedBody); - } catch (_) { - // Not valid JSON, keep as string - } + final blob = _isBlobPayload(widget.body); + if (blob.$1 != null) { + return _BlobInfo( + label: widget.label, + sizeBytes: blob.$2 ?? 0, + isDark: isDark, + ); } - final canToggle = parsedBody is Map || parsedBody is List; - // When the body is a primitive string, Tree mode can't show anything - // structured so we implicitly fall back to JSON mode. - final effectiveMode = canToggle ? viewMode : BodyViewMode.json; - // Look up the connected device's platform so Code mode exports the // right language. Falls back to TypeScript (RN) when not connected. final devices = ref.watch(connectedDevicesProvider); @@ -2436,85 +2730,97 @@ class _BodyTabState extends ConsumerState<_BodyTab> { 'react_native'; final codeLang = CodeGenerator.langForPlatform(platform); - return Column( - children: [ - // Toggle bar — 3-way Tree / JSON / Code segmented toggle - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), + return AsyncJsonParser( + rawData: widget.body, + builder: (context, parsedBody, isJson) { + final canToggle = isJson; + // When the body is a primitive string, Tree mode can't show anything + // structured so we implicitly fall back to JSON mode. + final effectiveMode = canToggle ? viewMode : BodyViewMode.json; + + return Column( + children: [ + // Toggle bar — 3-way Tree / JSON / Code segmented toggle + Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ), + child: Row( + children: [ + TextComponent(widget.label, style: theme.textTheme.titleSmall), + const Spacer(), + if (canToggle) ...[ + ViewModeSegment( + label: 'Tree', + active: effectiveMode == BodyViewMode.tree, + position: ViewSegmentPosition.start, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.tree), + ), + ViewModeSegment( + label: 'JSON', + active: effectiveMode == BodyViewMode.json, + position: ViewSegmentPosition.middle, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.json), + ), + ViewModeSegment( + label: CodeGenerator.labelFor(codeLang), + active: effectiveMode == BodyViewMode.code, + position: ViewSegmentPosition.end, + onTap: () => ref + .read(bodyViewModeProvider.notifier) + .set(BodyViewMode.code), + ), + ], + const SizedBox(width: 8), + // Copy body button + GestureDetector( + onTap: () { + final text = parsedBody is String + ? parsedBody + : const JsonEncoder.withIndent(' ') + .convert(parsedBody); + Clipboard.setData(ClipboardData(text: text)); + showCopiedToast(context, label: '${widget.label} copied'); + }, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Icon(LucideIcons.copy, + size: 14, color: Colors.grey[500]), + ), + ), + ], ), ), - ), - child: Row( - children: [ - TextComponent(widget.label, style: theme.textTheme.titleSmall), - const Spacer(), - if (canToggle) ...[ - ViewModeSegment( - label: 'Tree', - active: effectiveMode == BodyViewMode.tree, - position: ViewSegmentPosition.start, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.tree), - ), - ViewModeSegment( - label: 'JSON', - active: effectiveMode == BodyViewMode.json, - position: ViewSegmentPosition.middle, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.json), - ), - ViewModeSegment( - label: CodeGenerator.labelFor(codeLang), - active: effectiveMode == BodyViewMode.code, - position: ViewSegmentPosition.end, - onTap: () => ref - .read(bodyViewModeProvider.notifier) - .set(BodyViewMode.code), - ), - ], - const SizedBox(width: 8), - // Copy body button - GestureDetector( - onTap: () { - final text = parsedBody is String - ? parsedBody - : const JsonEncoder.withIndent(' ') - .convert(parsedBody); - Clipboard.setData(ClipboardData(text: text)); - showCopiedToast(context, label: '${widget.label} copied'); - }, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Icon(LucideIcons.copy, - size: 14, color: Colors.grey[500]), + // Body content — each viewer handles its own scrolling. + // Keeping bounded constraints so JsonPrettyViewer / JsonViewer + // can virtualize (shrinkWrap: false) instead of measuring + // every single line. + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildContent( + parsedBody: parsedBody, + canToggle: canToggle, + mode: effectiveMode, + codeLang: codeLang, ), ), - ], - ), - ), - // Body content - Expanded( - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: _buildContent( - parsedBody: parsedBody, - canToggle: canToggle, - mode: effectiveMode, - codeLang: codeLang, ), - ), - ), - ], + ], + ); + }, ); } @@ -2528,19 +2834,42 @@ class _BodyTabState extends ConsumerState<_BodyTab> { // Primitive / non-JSON body: only the pretty JSON viewer is meaningful. return JsonPrettyViewer(data: parsedBody); } - switch (mode) { - case BodyViewMode.tree: - return JsonViewer(data: parsedBody, initiallyExpanded: true); - case BodyViewMode.json: - return JsonPrettyViewer(data: parsedBody); - case BodyViewMode.code: - final generated = CodeGenerator.generate(parsedBody, codeLang); - return CodeViewer( - generated: generated, - lang: codeLang, - languageLabel: CodeGenerator.labelFor(codeLang), - ); + return DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer(data: parsedBody, initiallyExpanded: true); + case BodyViewMode.json: + return JsonPrettyViewer(data: widget.body); + case BodyViewMode.code: + final generated = CodeGenerator.generate(parsedBody, codeLang); + return SingleChildScrollView( + child: CodeViewer( + generated: generated, + lang: codeLang, + languageLabel: CodeGenerator.labelFor(codeLang), + ), + ); + } + }, + ); + } + + (String?, int?) _isBlobPayload(dynamic body) { + if (body is String) { + final t = body.trim(); + final m = RegExp(r'^<\s*(blob|arraybuffer)\s+(\d+)\s*bytes\s*>\s*$', + caseSensitive: false) + .firstMatch(t); + if (m != null) return (m.group(1), int.tryParse(m.group(2)!)); + final m2 = RegExp(r'^\s*$', caseSensitive: false) + .firstMatch(t); + if (m2 != null) return ('blob', int.tryParse(m2.group(1)!)); + final m3 = RegExp(r'^(\d+)\s*bytes$', caseSensitive: false).firstMatch(t); + if (m3 != null) return ('blob', int.tryParse(m3.group(1)!)); } + return (null, null); } } diff --git a/lib/features/network_inspector/provider/network_providers.dart b/lib/features/network_inspector/provider/network_providers.dart index 073f89c..5d4b388 100644 --- a/lib/features/network_inspector/provider/network_providers.dart +++ b/lib/features/network_inspector/provider/network_providers.dart @@ -80,7 +80,6 @@ bool _isBetterBody(dynamic body) { /// Merge two duplicate network entries, preferring the one with better data. NetworkEntry _mergeNetworkEntries(NetworkEntry existing, NetworkEntry incoming) { - // Prefer the entry with the more complete response body final useExistingBody = _isBetterBody(existing.responseBody); final useIncomingBody = _isBetterBody(incoming.responseBody); @@ -88,13 +87,16 @@ NetworkEntry _mergeNetworkEntries(NetworkEntry existing, NetworkEntry incoming) ? incoming.responseBody : (useExistingBody ? existing.responseBody : incoming.responseBody); + final bestRequestBody = _isBetterBody(incoming.requestBody) + ? incoming.requestBody + : existing.requestBody; + final bestStatusCode = incoming.statusCode != 0 ? incoming.statusCode : existing.statusCode; final bestError = incoming.error ?? existing.error; final bestIsComplete = incoming.isComplete || existing.isComplete; final bestEndTime = incoming.endTime ?? existing.endTime; final bestDuration = incoming.duration ?? existing.duration; - // Merge request headers from both sources final mergedReqHeaders = {...existing.requestHeaders, ...incoming.requestHeaders}; final mergedResHeaders = {...existing.responseHeaders, ...incoming.responseHeaders}; @@ -102,6 +104,7 @@ NetworkEntry _mergeNetworkEntries(NetworkEntry existing, NetworkEntry incoming) statusCode: bestStatusCode, requestHeaders: mergedReqHeaders, responseHeaders: mergedResHeaders, + requestBody: bestRequestBody, responseBody: bestBody, endTime: bestEndTime, duration: bestDuration, @@ -110,35 +113,33 @@ NetworkEntry _mergeNetworkEntries(NetworkEntry existing, NetworkEntry incoming) ); } +/// Requests that have been pending (no response) for longer than this +/// are considered stale — usually the client app crashed, the network +/// dropped, or the server never got a complete message. The toolbar +/// surfaces a "Clear stale (N)" button so users can prune them. +const Duration kStaleRequestThreshold = Duration(minutes: 10); + class NetworkNotifier extends StateNotifier> { late final StreamSubscription _sub; NetworkNotifier(WsMessageHandler wsMessageHandler) : super([]) { _sub = wsMessageHandler.onNetwork.listen((entry) { - // Update existing or add new + if (entry.method.toUpperCase() == 'OPTIONS') return; + if (entry.method.toUpperCase() == 'HEAD' && !entry.isComplete) return; + // Server guarantees unique ids, so a row always represents one + // logical request — update if seen before, otherwise append. final index = state.indexWhere((e) => e.id == entry.id); if (index >= 0) { final updated = List.from(state); - updated[index] = entry; + // Prefer the richer of the two rows — keeps completed data if + // we already have a partial one (start → complete round-trip). + updated[index] = _mergeNetworkEntries(state[index], entry); state = updated; } else { - // Deduplicate: same method+url+device within 500ms = duplicate interceptors - final dupeIndex = state.indexWhere((e) => - e.method == entry.method && - e.url == entry.url && - e.deviceId == entry.deviceId && - (e.startTime - entry.startTime).abs() < 500); - if (dupeIndex >= 0) { - final merged = _mergeNetworkEntries(state[dupeIndex], entry); - final updated = List.from(state); - updated[dupeIndex] = merged; - state = updated; + if (state.length > 5000) { + state = [...state.skip(500), entry]; } else { - if (state.length > 5000) { - state = [...state.skip(500), entry]; - } else { - state = [...state, entry]; - } + state = [...state, entry]; } } }); @@ -147,4 +148,37 @@ class NetworkNotifier extends StateNotifier> { void cancelSubscription() => _sub.cancel(); void clear() => state = []; + + /// Count entries still waiting for a response after the stale threshold. + /// These usually mean the client crashed before sending a complete, or + /// the network died mid-flight. + int countStale({DateTime? now}) { + final cutoff = (now ?? DateTime.now()) + .subtract(kStaleRequestThreshold) + .millisecondsSinceEpoch; + return state.where((e) => !e.isComplete && e.startTime < cutoff).length; + } + + /// Drop every stale entry. Returns how many rows were removed so the + /// UI can show a confirmation toast. + int clearStale({DateTime? now}) { + final cutoff = (now ?? DateTime.now()) + .subtract(kStaleRequestThreshold) + .millisecondsSinceEpoch; + final before = state.length; + state = state + .where((e) => e.isComplete || e.startTime >= cutoff) + .toList(growable: false); + return before - state.length; + } } + +/// Live count of stale (unanswered > 10min) network entries. Drives the +/// "Clear stale (N)" button visibility on the network toolbar. +final staleNetworkCountProvider = Provider((ref) { + final entries = ref.watch(networkEntriesProvider); + final cutoff = DateTime.now() + .subtract(kStaleRequestThreshold) + .millisecondsSinceEpoch; + return entries.where((e) => !e.isComplete && e.startTime < cutoff).length; +}); diff --git a/lib/features/performance/presentation/pages/memory_leaks_page.dart b/lib/features/performance/presentation/pages/memory_leaks_page.dart index f914a2e..4fe959e 100644 --- a/lib/features/performance/presentation/pages/memory_leaks_page.dart +++ b/lib/features/performance/presentation/pages/memory_leaks_page.dart @@ -9,6 +9,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../../core/constants/app_constants.dart'; import '../../../../components/text/text_component.dart'; import '../../../../core/utils/toast_utils.dart'; @@ -685,11 +686,7 @@ class _LeakDetailState extends State<_LeakDetail> { ); } - String _formatBytes(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } + String _formatBytes(int bytes) => AppConstants.formatBytes(bytes); } // ---- Detail Section ---- diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 7bb4bf8..ec84c20 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1252,6 +1252,17 @@ Future _resolveAdbPath() async { class _DetailViewSection extends ConsumerWidget { const _DetailViewSection(); + String _modeDescription(WidgetRef ref, BodyViewMode mode) { + switch (mode) { + case BodyViewMode.tree: + return S.of(ref.context).treeModeDesc; + case BodyViewMode.json: + return S.of(ref.context).jsonModeDesc; + case BodyViewMode.code: + return S.of(ref.context).codeModeDesc; + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final viewMode = ref.watch(bodyViewModeProvider); @@ -1317,7 +1328,7 @@ class _DetailViewSection extends ConsumerWidget { Padding( padding: const EdgeInsets.only(left: 100), child: Text( - S.of(context).codeModeDesc, + _modeDescription(ref, viewMode), 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 a8db35b..2cc24f8 100644 --- a/lib/features/state_inspector/presentation/pages/state_inspector_page.dart +++ b/lib/features/state_inspector/presentation/pages/state_inspector_page.dart @@ -12,6 +12,7 @@ import '../../../../components/viewers/json_viewer.dart'; import '../../../../core/theme/color_tokens.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../core/utils/screenshot_filename.dart'; import '../../../../models/state/state_change.dart'; import '../../../../components/lists/stable_list_view.dart'; import '../../../../components/misc/jump_to_latest_fab.dart'; @@ -208,17 +209,18 @@ class _StateInspectorPageState extends ConsumerState { }, builder: (context, index) { final entry = _entries[index]; - final isSelected = selected?.id == entry.id; return RepaintBoundary( key: ValueKey(entry.id), child: _StateChangeTile( entry: entry, - isSelected: isSelected, onTap: () { + final currentlySelected = + ref.read(selectedStateChangeIdProvider) == + entry.id; ref .read(selectedStateChangeIdProvider.notifier) - .state = isSelected ? null : entry.id; - if (!isSelected && _autoScroll) { + .state = currentlySelected ? null : entry.id; + if (!currentlySelected && _autoScroll) { _autoScroll = false; _programmaticScroll = false; if (_scrollController.hasClients) { @@ -353,20 +355,20 @@ class _Toolbar extends ConsumerWidget { } } -class _StateChangeTile extends StatelessWidget { +class _StateChangeTile extends ConsumerWidget { final StateChange entry; - final bool isSelected; final VoidCallback onTap; const _StateChangeTile({ super.key, required this.entry, - required this.isSelected, required this.onTap, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final selectedId = ref.watch(selectedStateChangeIdProvider); + final isSelected = selectedId == entry.id; final isDark = Theme.of(context).brightness == Brightness.dark; final time = DateFormat('HH:mm:ss.SSS').format( DateTime.fromMillisecondsSinceEpoch(entry.timestamp), @@ -492,6 +494,15 @@ class _StateDetailPanelState extends State<_StateDetailPanel> { StateChange get entry => widget.entry; void _takeScreenshot(BuildContext context, bool isDark) { + // Resolve a descriptive file name: state___full.png + final fileName = buildRichScreenshotName( + type: 'state', + subject: entry.actionName.isNotEmpty + ? entry.actionName + : entry.stateManagerType, + suffix: '_full', + ); + final screenshotWidget = Container( color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, child: Column( @@ -600,7 +611,7 @@ class _StateDetailPanelState extends State<_StateDetailPanel> { ], ), ); - captureWidgetAsImage(context, screenshotWidget); + captureWidgetAsImage(context, screenshotWidget, fileName: fileName); } @override @@ -709,17 +720,23 @@ class _StateDetailPanelState extends State<_StateDetailPanel> { Expanded( child: TabBarView( children: [ - // Diff tab - _DiffView(diff: entry.diff), - // Before tab - _StateJsonToggleView( - data: entry.previousState, - jsonMode: _jsonPrettyMode, + LazyTab( + index: 0, + builder: (_) => _DiffView(diff: entry.diff), ), - // After tab - _StateJsonToggleView( - data: entry.nextState, - jsonMode: _jsonPrettyMode, + LazyTab( + index: 1, + builder: (_) => _StateJsonToggleView( + data: entry.previousState, + jsonMode: _jsonPrettyMode, + ), + ), + LazyTab( + index: 2, + builder: (_) => _StateJsonToggleView( + data: entry.nextState, + jsonMode: _jsonPrettyMode, + ), ), ], ), 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 0a9849b..2870b8f 100644 --- a/lib/features/storage_viewer/presentation/pages/storage_viewer_page.dart +++ b/lib/features/storage_viewer/presentation/pages/storage_viewer_page.dart @@ -12,7 +12,9 @@ import '../../../../components/feedback/empty_state.dart'; import '../../../../components/inputs/search_field.dart'; import '../../../../components/viewers/json_viewer.dart'; import '../../../../core/theme/color_tokens.dart'; +import '../../../../core/utils/code_generator.dart'; import '../../../../core/utils/screenshot_utils.dart'; +import '../../../../core/utils/screenshot_filename.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../components/text/text_component.dart'; import '../../../../models/storage/storage_entry.dart'; @@ -20,6 +22,7 @@ import '../../../../components/lists/stable_list_view.dart'; import '../../../../components/misc/jump_to_latest_fab.dart'; import '../../../../core/utils/position_retained_scroll_physics.dart'; import '../../../../core/utils/smooth_scroll_controller.dart'; +import '../../../../server/providers/server_providers.dart'; import '../../provider/storage_providers.dart'; class StorageViewerPage extends ConsumerStatefulWidget { @@ -58,6 +61,18 @@ class _StorageViewerPageState extends ConsumerState { }, fireImmediately: true, ); + // Selection changes must also bump generation — StableBuilderDelegate + // short-circuits shouldRebuild when generation is unchanged, which + // would otherwise leave tile decorations (selected bg, accent border) + // stuck on the previously-selected item. + ref.listenManual( + selectedStorageIdProvider, + (_, _) { + _generation++; + setState(() {}); + }, + fireImmediately: false, + ); } void _onScroll() { @@ -236,6 +251,14 @@ class _StorageViewerPageState extends ConsumerState { Expanded( flex: 3, child: _StorageDetailPanel( + // Recycle the panel state when the entry + // changes — without this, Flutter reuses + // the State across entries, which can + // leave the GestureDetector inside the + // JsonPrettyViewer's deferred subtree in + // a state that captures subsequent pointer + // events. + key: ValueKey(selected.id), entry: selected, onClose: () => ref .read(selectedStorageIdProvider.notifier) @@ -674,6 +697,11 @@ class _StorageEntryTile extends StatelessWidget { return GestureDetector( onTap: onTap, + // Opaque hit-testing so taps land on the tile's full area — without + // this, an outer MouseRegion can swallow the hit for hover handling + // before the GestureDetector sees it, breaking subsequent taps after + // a rebuild. + behavior: HitTestBehavior.opaque, child: MouseRegion( cursor: SystemMouseCursors.click, child: Container( @@ -794,20 +822,21 @@ class _StorageEntryTile extends StatelessWidget { } } -class _StorageDetailPanel extends StatefulWidget { +class _StorageDetailPanel extends ConsumerStatefulWidget { final StorageEntry entry; final VoidCallback onClose; - const _StorageDetailPanel({required this.entry, required this.onClose}); + const _StorageDetailPanel({ + super.key, + required this.entry, + required this.onClose, + }); @override - State<_StorageDetailPanel> createState() => _StorageDetailPanelState(); + ConsumerState<_StorageDetailPanel> createState() => _StorageDetailPanelState(); } -class _StorageDetailPanelState extends State<_StorageDetailPanel> { - bool _formatted = false; - bool _jsonMode = false; - bool _jsonEverOpened = false; +class _StorageDetailPanelState extends ConsumerState<_StorageDetailPanel> { final _scrollController = SmoothScrollController(); @override @@ -818,73 +847,336 @@ class _StorageDetailPanelState extends State<_StorageDetailPanel> { StorageEntry get entry => widget.entry; - void _takeScreenshot(BuildContext context, bool isDark) { - final isAlreadyJson = entry.value is Map || entry.value is List; - final parsedJson = isAlreadyJson ? null : _tryParseJson(entry.value); + /// Stats used by the metadata footer + the JSON-mode empty state. + String _valueStats() { + final v = entry.value; + if (v == null) return 'null'; + if (v is Map) { + final n = v.length; + return '$n ${n == 1 ? 'key' : 'keys'} · ${v.values.length} values'; + } + if (v is List) return '${v.length} items'; + final s = v.toString(); + if (s.length > 24) return '${s.length} chars'; + return s; + } - final screenshotWidget = Container( - color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + String _sizeLabel() { + final v = entry.value; + if (v == null) return '0 B'; + final raw = v is String ? v : jsonEncode(v); + return AppConstants.formatBytes(raw.length); + } + + /// Human-friendly shape label (mirrors the in-app bento grid). + String _shapeOf(dynamic v) { + if (v == null) return 'null'; + if (v is Map) { + return 'Map · ${v.length} ${v.length == 1 ? "key" : "keys"}'; + } + if (v is List) { + return 'List · ${v.length} ${v.length == 1 ? "item" : "items"}'; + } + if (v is String) { + if (v.isEmpty) return 'String · empty'; + final t = v.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || + (t.startsWith('[') && t.endsWith(']'))) { + return 'String · JSON-shaped'; + } + return 'String'; + } + return v.runtimeType.toString(); + } + + void _takeFullScreenshot(BuildContext context, bool isDark) { + _captureStorage(context, isDark, full: true); + } + + void _takeDataScreenshot(BuildContext context, bool isDark) { + _captureStorage(context, isDark, full: false); + } + + /// Builds the capture widget for either the full (header + metadata + value) + /// or data-only (key + value) screenshot. Uses the same JSON detection as + /// the live panel so non-JSON values render as raw text instead of + /// crashing inside JsonViewer/CodeViewer. + void _captureStorage(BuildContext context, bool isDark, + {required bool full}) { + final value = entry.value; + final isAlreadyJson = value is Map || value is List; + dynamic parsedJson; + if (!isAlreadyJson && value is String) { + try { + parsedJson = jsonDecode(value); + if (parsedJson is! Map && parsedJson is! List) parsedJson = null; + } catch (_) {} + } + + final isJsonLike = isAlreadyJson || parsedJson != null; + final displayValue = isAlreadyJson + ? value + : (parsedJson ?? value); + final mode = ref.read(bodyViewModeProvider); + final devices = ref.read(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == entry.deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); + + final fileName = buildRichScreenshotName( + type: 'storage', + subject: '${entry.storageType.name}_${entry.key}', + suffix: full ? '_full' : '_data', + ); + + // Build the value widget — respects 3 modes only when JSON-like, + // otherwise renders raw text (same as the live panel). + final Widget valueWidget; + if (isJsonLike) { + valueWidget = switch (mode) { + BodyViewMode.tree => + JsonViewer(data: displayValue, initiallyExpanded: true), + BodyViewMode.json => JsonPrettyViewer(data: displayValue), + BodyViewMode.code => CodeViewer( + generated: CodeGenerator.generate(displayValue, codeLang), + lang: codeLang, + languageLabel: codeLabel, + ), + }; + } else { + valueWidget = Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF1E1E1E) + : const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + ), + ), + child: SelectableText( + value?.toString() ?? 'null', + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: isDark ? const Color(0xFFD4D4D4) : const Color(0xFF1F2328), + ), + ), + ); + } + + final divider = isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06); + + // Operation color matches the in-app panel: + // write → emerald, read → blue, delete/clear → red, default → amber. + final opColor = _opColorFor(entry.operation); + final opUpper = entry.operation.toUpperCase(); + + final children = [ + // ── Badges (WRITE chip + asyncStorage chip) ─────────────── + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - TextComponent(S.of(context).key, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.grey[500])), - const SizedBox(height: 4), - TextComponent(entry.key, + // Operation badge: dot + uppercase label + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: opColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: opColor.withValues(alpha: 0.28), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: opColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + TextComponent( + opUpper, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + fontWeight: FontWeight.w700, + color: opColor, + letterSpacing: 0.6, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Storage type badge (e.g. asyncStorage) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.10) + : Colors.black.withValues(alpha: 0.08), + ), + ), + child: TextComponent( + entry.storageType.name, style: TextStyle( fontFamily: AppConstants.monoFontFamily, - fontSize: 13, + fontSize: 11, fontWeight: FontWeight.w600, - color: ColorTokens.primary, - )), - const SizedBox(height: 16), - TextComponent(S.of(context).value, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.grey[500])), - const SizedBox(height: 4), - if (isAlreadyJson) - _jsonMode - ? JsonPrettyViewer(data: entry.value) - : JsonViewer(data: entry.value, initiallyExpanded: true) - else if (_formatted && parsedJson != null) - _jsonMode - ? JsonPrettyViewer(data: parsedJson) - : JsonViewer(data: parsedJson, initiallyExpanded: true) - else - JsonPrettyViewer(data: entry.value), - const SizedBox(height: 16), - TextComponent(S.of(context).metadata, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.grey[500])), - const SizedBox(height: 4), - TextComponent( - 'Type: ${entry.storageType.name} | Operation: ${entry.operation}', - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 11, - color: isDark ? Colors.white70 : Colors.black54)), + color: isDark + ? const Color(0xFFB0B0B0) + : const Color(0xFF4A4A4A), + letterSpacing: 0.3, + ), + ), + ), ], ), + const SizedBox(height: 16), + // ── KEY section ─────────────────────────────────────────── + TextComponent(S.of(context).key, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: Colors.grey[500])), + const SizedBox(height: 4), + SelectableText( + entry.key, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A), + height: 1.4, + ), + ), + const SizedBox(height: 18), + Container(height: 1, color: divider), + const SizedBox(height: 18), + // ── VALUE section ───────────────────────────────────────── + TextComponent(S.of(context).value, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: Colors.grey[500])), + const SizedBox(height: 8), + valueWidget, + ]; + + // Metadata bento grid — only the full capture gets this section, + // matching the in-app panel (SHAPE / SIZE / DEVICE / CAPTURED). + if (full) { + final capturedAt = DateFormat('HH:mm:ss.SSS').format( + DateTime.fromMillisecondsSinceEpoch(entry.timestamp), + ); + final monoPrimary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + height: 1.5, + color: + isDark ? const Color(0xFFE8E8E8) : const Color(0xFF1A1A1A), + ); + final monoSecondary = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + height: 1.5, + color: isDark ? Colors.grey[500] : Colors.grey[600], + ); + final metaLabelStyle = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: isDark ? Colors.grey[500] : Colors.grey[600], + ); + + Widget metaCell(String label, String value, TextStyle valueStyle, + {bool monospace = false}) => + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextComponent(label, style: metaLabelStyle), + const SizedBox(height: 4), + TextComponent( + value, + style: monospace + ? valueStyle + : valueStyle.copyWith( + fontFamily: AppConstants.monoFontFamily), + ), + ], + ); + + children.addAll([ + const SizedBox(height: 22), + TextComponent('METADATA', style: metaLabelStyle), + const SizedBox(height: 10), + // Row 1: SHAPE / SIZE + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: metaCell('SHAPE', _shapeOf(value), monoPrimary)), + const SizedBox(width: 12), + Expanded(child: metaCell('SIZE', _sizeLabel(), monoPrimary)), + ], + ), + const SizedBox(height: 12), + // Row 2: DEVICE / CAPTURED + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: metaCell('DEVICE', entry.deviceId, monoSecondary), + ), + const SizedBox(width: 12), + Expanded(child: metaCell('CAPTURED', capturedAt, monoPrimary)), + ], + ), + ]); + } + + final screenshotWidget = Container( + color: isDark ? ColorTokens.darkSurface : ColorTokens.lightSurface, + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), ); - captureWidgetAsImage(context, screenshotWidget); + captureWidgetAsImage(context, screenshotWidget, fileName: fileName); } - dynamic _tryParseJson(dynamic value) { - if (value is! String) return null; - try { - final parsed = jsonDecode(value); - if (parsed is Map || parsed is List) return parsed; - } catch (_) {} - return null; + /// Returns the accent color for a storage operation. + static Color _opColorFor(String op) { + switch (op.toLowerCase()) { + case 'write': + return const Color(0xFF34D399); // emerald 400 + case 'read': + return const Color(0xFF60A5FA); // blue 400 + case 'delete': + case 'clear': + return const Color(0xFFF87171); // red 400 + default: + return const Color(0xFFFBBF24); // amber 400 + } } @override @@ -894,10 +1186,16 @@ class _StorageDetailPanelState extends State<_StorageDetailPanel> { final time = DateFormat('yyyy-MM-dd HH:mm:ss.SSS').format( DateTime.fromMillisecondsSinceEpoch(entry.timestamp), ); + final mode = ref.watch(bodyViewModeProvider); - final isAlreadyJson = entry.value is Map || entry.value is List; - final parsedJson = isAlreadyJson ? null : _tryParseJson(entry.value); - final canFormat = parsedJson != null; + final devices = ref.watch(connectedDevicesProvider); + final platform = devices + .where((d) => d.deviceId == entry.deviceId) + .map((d) => d.platform) + .firstOrNull ?? + 'react_native'; + final codeLang = CodeGenerator.langForPlatform(platform); + final codeLabel = CodeGenerator.labelFor(codeLang); final opColor = _StorageEntryTile._opColor(entry.operation); final tColor = _StorageEntryTile._typeColor(entry.storageType); @@ -908,8 +1206,7 @@ class _StorageDetailPanelState extends State<_StorageDetailPanel> { children: [ // Header Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), decoration: BoxDecoration( color: isDark ? ColorTokens.darkBackground : Colors.white, border: Border( @@ -920,271 +1217,429 @@ class _StorageDetailPanelState extends State<_StorageDetailPanel> { ), ), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(LucideIcons.database, size: 14, color: ColorTokens.primary), - const SizedBox(width: 8), - // Operation badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: opColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: TextComponent( - entry.operation.toUpperCase(), - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w800, - color: opColor, + Row( + children: [ + Icon(LucideIcons.database, size: 14, color: ColorTokens.primary), + const SizedBox(width: 8), + Expanded( + child: TextComponent( + entry.key, + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 13, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ), + _DetailIconBtn( + icon: LucideIcons.camera, + tooltip: S.of(context).captureFullTooltip, + isDark: isDark, + onTap: () => _takeFullScreenshot(context, isDark), + ), + const SizedBox(width: 4), + _DetailIconBtn( + icon: LucideIcons.scanLine, + tooltip: S.of(context).captureTabTooltip, + isDark: isDark, + onTap: () => _takeDataScreenshot(context, isDark), + ), + const SizedBox(width: 4), + _DetailIconBtn( + icon: LucideIcons.x, + tooltip: S.of(context).close, + isDark: isDark, + onTap: widget.onClose, + ), + ], ), - const SizedBox(width: 6), - // Type badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: tColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: TextComponent( - entry.storageType.name, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, + const SizedBox(height: 8), + Row( + children: [ + _MetaChip( + icon: _opIcon(entry.operation), + label: entry.operation.toUpperCase(), + color: opColor, + isDark: isDark, + ), + const SizedBox(width: 6), + _MetaChip( + icon: LucideIcons.database, + label: entry.storageType.name, color: tColor, + isDark: isDark, ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: TextComponent( - entry.key, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 12, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white : Colors.black87, + const SizedBox(width: 6), + _MetaChip( + icon: LucideIcons.clock, + label: time, + color: Colors.grey, + isDark: isDark, + isMono: true, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - TextComponent( - time, - style: TextStyle( - fontFamily: AppConstants.monoFontFamily, - fontSize: 10, - color: Colors.grey[500], - ), - ), - const SizedBox(width: 10), - _DetailIconBtn( - icon: LucideIcons.camera, - tooltip: S.of(context).captureAsImage, - isDark: isDark, - onTap: () => _takeScreenshot(context, isDark), - ), - const SizedBox(width: 4), - _DetailIconBtn( - icon: LucideIcons.x, - tooltip: S.of(context).close, - isDark: isDark, - onTap: widget.onClose, + const Spacer(), + ], ), ], ), ), // Content Expanded( - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Value section - Row( - children: [ - TextComponent('Value', style: theme.textTheme.titleSmall), - const Spacer(), - if (isAlreadyJson || (canFormat && _formatted && parsedJson != null)) ...[ - _ViewModeToggle( - isJsonMode: _jsonMode, - onToggle: () => setState(() { - _jsonMode = !_jsonMode; - if (_jsonMode) _jsonEverOpened = true; - }), - isDark: isDark, + child: AsyncJsonParser( + rawData: entry.value, + builder: (context, parsedJson, isJson) { + final isAlreadyJson = + entry.value is Map || entry.value is List; + // For Tree/JSON: prefer the parsed JSON when available so + // string-encoded JSON renders correctly in both modes. + final displayValue = isAlreadyJson + ? entry.value + : (parsedJson ?? entry.value); + + // Plain text payload: skip the 3-mode toggle entirely and + // show the raw value as a single styled block. Users can + // still copy via the icon-button in the header. + if (!isJson) { + return SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + TextComponent( + 'Value', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(width: 8), + _MetaChip( + icon: LucideIcons.hardDrive, + label: _sizeLabel(), + color: Colors.grey, + isDark: isDark, + isMono: true, + ), + ], ), - const SizedBox(width: 8), - ], - if (canFormat && !isAlreadyJson) - _FormatToggle( - isFormatted: _formatted, - onToggle: () => - setState(() => _formatted = !_formatted), - isDark: isDark, + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF1E1E1E) + : const Color(0xFFFAFAFA), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.08), + ), + ), + child: SelectableText( + entry.value?.toString() ?? 'null', + style: TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 12, + height: 1.6, + color: isDark + ? const Color(0xFFD4D4D4) + : const Color(0xFF1F2328), + ), + ), ), + const SizedBox(height: 20), + const _MetaDivider(), + const SizedBox(height: 12), + _MetadataFooter( + entry: entry, + isDark: isDark, + stats: _valueStats(), + ), ], ), - const SizedBox(height: 8), - if (_jsonMode && (isAlreadyJson || (_formatted && parsedJson != null))) ...[ - if (_jsonEverOpened) - JsonPrettyViewer(data: isAlreadyJson ? entry.value : parsedJson), - ] else ...[ - if (isAlreadyJson) - JsonViewer(data: entry.value, initiallyExpanded: true) - else if (_formatted && parsedJson != null) - JsonViewer(data: parsedJson, initiallyExpanded: true) - else - JsonViewer(data: entry.value, initiallyExpanded: false), - ], - ], - ), + ); + } + + return SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section header + Row( + children: [ + TextComponent( + 'Value', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + const SizedBox(width: 8), + _MetaChip( + icon: LucideIcons.hardDrive, + label: _sizeLabel(), + color: Colors.grey, + isDark: isDark, + isMono: true, + ), + ], + ), + const SizedBox(height: 10), + // View switcher (own row so its inner Expanded row gets + // a real width — placing it inside a parent Row collapsed + // the Container's intrinsic width to ~0, hiding it). + SizedBox( + width: double.infinity, + child: ViewModeSwitcher( + current: mode, + codeLabel: codeLabel, + onChanged: (BodyViewMode m) => + ref.read(bodyViewModeProvider.notifier).set(m), + ), + ), + const SizedBox(height: 12), + DeferredBuilder( + key: ValueKey(mode), + builder: (_) { + switch (mode) { + case BodyViewMode.tree: + return JsonViewer( + data: displayValue, + initiallyExpanded: true, + ); + case BodyViewMode.json: + return JsonPrettyViewer(data: displayValue); + case BodyViewMode.code: + return CodeViewer( + generated: CodeGenerator.generate( + displayValue, + codeLang, + ), + lang: codeLang, + languageLabel: codeLabel, + ); + } + }, + ), + const SizedBox(height: 20), + const _MetaDivider(), + const SizedBox(height: 12), + _MetadataFooter( + entry: entry, + isDark: isDark, + stats: _valueStats(), + ), + ], + ), + ); + }, ), ), ], ), ); } + + IconData _opIcon(String op) { + switch (op.toLowerCase()) { + case 'write': + return LucideIcons.pencilLine; + case 'delete': + case 'clear': + return LucideIcons.trash2; + default: + return LucideIcons.eye; + } + } } -class _DetailIconBtn extends StatelessWidget { +class _MetaChip extends StatelessWidget { final IconData icon; - final String tooltip; + final String label; + final Color color; final bool isDark; - final VoidCallback onTap; + final bool isMono; - const _DetailIconBtn({ + const _MetaChip({ required this.icon, - required this.tooltip, + required this.label, + required this.color, required this.isDark, - required this.onTap, + this.isMono = false, }); @override Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: GestureDetector( - onTap: onTap, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06), + return Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: color == Colors.grey + ? (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.04)) + : color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 10, + color: color == Colors.grey ? Colors.grey[500] : color, + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontFamily: isMono ? AppConstants.monoFontFamily : null, + fontSize: 9, + fontWeight: FontWeight.w700, + color: color == Colors.grey ? Colors.grey[500] : color, + letterSpacing: isMono ? -0.1 : 0.2, ), - child: Icon(icon, size: 13, color: Colors.grey[500]), ), - ), + ], ), ); } } -class _ViewModeToggle extends StatelessWidget { - final bool isJsonMode; - final VoidCallback onToggle; +class _MetaDivider extends StatelessWidget { + const _MetaDivider(); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Row( + children: [ + TextComponent( + 'Metadata', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + color: isDark ? Colors.grey[400] : Colors.grey[600], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Container( + height: 1, + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + ), + ], + ); + } +} + +class _MetadataFooter extends StatelessWidget { + final StorageEntry entry; final bool isDark; + final String stats; - const _ViewModeToggle({ - required this.isJsonMode, - required this.onToggle, + const _MetadataFooter({ + required this.entry, required this.isDark, + required this.stats, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onToggle, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: isJsonMode - ? ColorTokens.primary.withValues(alpha: 0.15) - : (isDark - ? Colors.white.withValues(alpha: 0.06) - : Colors.black.withValues(alpha: 0.06)), - ), + final labelColor = isDark ? Colors.grey[500] : Colors.grey[600]; + final valueColor = isDark ? Colors.white70 : Colors.black87; + final monoStyle = TextStyle( + fontFamily: AppConstants.monoFontFamily, + fontSize: 11, + color: valueColor, + ); + + Widget row(String label, String value) => Padding( + padding: const EdgeInsets.only(bottom: 6), child: Row( - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - isJsonMode ? LucideIcons.braces : LucideIcons.list, - size: 12, - color: isJsonMode ? ColorTokens.primary : Colors.grey[500], + SizedBox( + width: 96, + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: labelColor, + ), + ), ), - const SizedBox(width: 4), - TextComponent( - isJsonMode ? S.of(context).json : S.of(context).tree, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: isJsonMode ? ColorTokens.primary : Colors.grey[500], + Expanded( + child: Text( + value, + style: monoStyle, + softWrap: true, ), ), ], ), - ), - ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + row('Type', entry.storageType.name), + row('Operation', entry.operation), + row('Shape', stats), + row('Device', entry.deviceId), + ], ); } } -class _FormatToggle extends StatelessWidget { - final bool isFormatted; - final VoidCallback onToggle; +class _DetailIconBtn extends StatelessWidget { + final IconData icon; + final String tooltip; final bool isDark; + final VoidCallback onTap; - const _FormatToggle({ - required this.isFormatted, - required this.onToggle, + const _DetailIconBtn({ + required this.icon, + required this.tooltip, required this.isDark, + required this.onTap, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onToggle, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - color: isFormatted - ? ColorTokens.primary.withValues(alpha: 0.15) - : (isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.06)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.braces, - size: 12, - color: isFormatted ? ColorTokens.primary : Colors.grey[500], - ), - const SizedBox(width: 4), - TextComponent( - isFormatted ? S.of(context).raw : S.of(context).format, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: isFormatted ? ColorTokens.primary : Colors.grey[500], - ), - ), - ], + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: isDark + ? Colors.white.withValues(alpha: 0.06) + : Colors.black.withValues(alpha: 0.06), + ), + child: Icon(icon, size: 13, color: Colors.grey[500]), ), ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e852c11..e6c63db 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -123,6 +123,11 @@ "tabAnimation": "Tab animation", "tabAnimationDuration": "Duration", "codeModeDesc": "Code mode exports as TypeScript / Dart / Kotlin based on the connected SDK.", + "treeModeDesc": "Tree mode shows the data as an expandable, collapsible node hierarchy. Best for navigating deeply nested values.", + "jsonModeDesc": "JSON mode renders the data as a single, syntax-highlighted, copy-friendly JSON document.", + "captureDataJson": "Capture data (key + value in current mode)", + "captureDataText": "Capture data (key + value as text)", + "copyKey": "Copy key", "usbConnection": "USB Connection", "android": "Android", "ios": "iOS", @@ -157,6 +162,17 @@ "history": "History", "noNetworkRequests": "No network requests", "apiCallsAppearHere": "API calls will appear here in real-time", + "clearStaleButton": "Stale ({count})", + "clearStaleTooltip": "Clear {count} pending request(s) with no response for over 10 minutes", + "clearStaleSnackbar": "Cleared {count} stale request(s) (pending > 10min)", + "memorySafetyOverflow": "Memory safety: dropped {count} stale network entry(ies) from the open-trips cache", + "sdkTipsPill": "Tips", + "sdkTipsHeader": "Library compatibility", + "sdkTipsSubtitle": "To ensure all data is fully displayed, make sure the libraries are the latest version.", + "sdkTipsFlutter": "Flutter", + "sdkTipsReactNative": "React Native", + "sdkTipsAndroid": "Android", + "sdkTipsVersionLabel": "v{version}", "networkTitle": "Network", "filterUrls": "Filter URLs...", "copyUrl": "Copy URL", @@ -297,5 +313,17 @@ "smoothScrolling": "Smooth scroll", "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." + "smoothScrollingDurationDesc": "The duration of the smooth scroll animation in milliseconds.", + "binaryBody": "{label} body is binary", + "@binaryBody": { + "placeholders": { "label": { "type": "String" } } + }, + "binaryBodySize": "{kb} KB ({bytes} bytes)", + "@binaryBodySize": { + "placeholders": { + "kb": { "type": "String" }, + "bytes": { "type": "int" } + } + }, + "binaryBodyHint": "Identify the action via the X-Amz-Target header." } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 09295fd..fa7dda3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -78,7 +78,7 @@ "duration": "Durée", "error": "Erreur", "json": "JSON", - "tree": "Arborescence", + "tree": "Tree", "code": "Code", "raw": "Brut", "format": "Format", @@ -123,6 +123,11 @@ "tabAnimation": "Animation d'onglet", "tabAnimationDuration": "Durée", "codeModeDesc": "Le mode code exporte en TypeScript / Dart / Kotlin selon le SDK connecté.", + "captureDataJson": "Capturer les données (clé + valeur dans le mode actuel)", + "captureDataText": "Capturer les données (clé + valeur sous forme de texte)", + "copyKey": "Copier la clé", + "treeModeDesc": "Le mode arborescence affiche les données sous forme de hiérarchie de nœuds dépliables. Idéal pour parcourir des valeurs profondément imbriquées.", + "jsonModeDesc": "Le mode JSON affiche les données sous forme de document JSON unique, coloré syntaxiquement et facile à copier.", "usbConnection": "Connexion USB", "android": "Android", "ios": "iOS", @@ -157,6 +162,17 @@ "history": "Historique", "noNetworkRequests": "Aucune requête réseau", "apiCallsAppearHere": "Les appels API apparaîtront ici en temps réel", + "clearStaleButton": "Obsolète ({count})", + "clearStaleTooltip": "Supprimer {count} requêtes en attente sans réponse depuis plus de 10 minutes", + "clearStaleSnackbar": "{count} requête(s) obsolète(s) supprimée(s) (en attente > 10 min)", + "memorySafetyOverflow": "Sécurité mémoire: {count} entrée(s) réseau obsolète(s) supprimée(s) du cache open-trips", + "sdkTipsPill": "Astuces", + "sdkTipsHeader": "Compatibilité des bibliothèques", + "sdkTipsSubtitle": "Pour que toutes les données s'affichent correctement, assurez-vous que les bibliothèques sont à la dernière version.", + "sdkTipsFlutter": "Flutter", + "sdkTipsReactNative": "React Native", + "sdkTipsAndroid": "Android", + "sdkTipsVersionLabel": "v{version}", "networkTitle": "Réseau", "filterUrls": "Filtrer les URL...", "copyUrl": "Copier l'URL", @@ -297,5 +313,8 @@ "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.", "smoothScrollingDuration": "Durée du défilement", - "smoothScrollingDurationDesc": "La durée de l'animation de défilement en millisecondes." + "smoothScrollingDurationDesc": "La durée de l'animation de défilement en millisecondes.", + "binaryBody": "Le corps de {label} est binaire", + "binaryBodySize": "{kb} Ko ({bytes} octets)", + "binaryBodyHint": "Identifiez l'action via l'en-tête X-Amz-Target." } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 8717a63..3164d37 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -78,8 +78,8 @@ "duration": "期間", "error": "エラー", "json": "JSON", - "tree": "ツリー", - "code": "コード", + "tree": "Tree", + "code": "Code", "raw": "Raw", "format": "フォーマット", "pretty": "整形", @@ -123,6 +123,11 @@ "tabAnimation": "タブアニメーション", "tabAnimationDuration": "期間", "codeModeDesc": "コードモードは接続されたSDKに基づいてTypeScript / Dart / Kotlinとしてエクスポートします。", + "captureDataJson": "データをキャプチャ (現在のモードのキー + 値)", + "captureDataText": "データをキャプチャ (キー + 値をテキストとして)", + "copyKey": "キーをコピー", + "treeModeDesc": "ツリーモードはデータを展開/折りたたみ可能なノード階層で表示します。深くネストされた値の閲覧に適しています。", + "jsonModeDesc": "JSONモードはデータを構文強調表示付きの単一のコピーしやすいJSONドキュメントとして表示します。", "usbConnection": "USB接続", "android": "Android", "ios": "iOS", @@ -157,6 +162,17 @@ "history": "履歴", "noNetworkRequests": "ネットワークリクエストなし", "apiCallsAppearHere": "APIコールがリアルタイムでここに表示されます", + "clearStaleButton": "古い ({count})", + "clearStaleTooltip": "10分以上応答のない保留中のリクエスト{count}件を削除", + "clearStaleSnackbar": "古いリクエスト{count}件を削除しました (保留中 > 10分)", + "memorySafetyOverflow": "メモリ保護: open-trips キャッシュから古いネットワーク エントリ {count} 件を削除しました", + "sdkTipsPill": "ヒント", + "sdkTipsHeader": "ライブラリの互換性", + "sdkTipsSubtitle": "すべてのデータが完全に表示されるよう、ライブラリが最新バージョンであることを確認してください。", + "sdkTipsFlutter": "Flutter", + "sdkTipsReactNative": "React Native", + "sdkTipsAndroid": "Android", + "sdkTipsVersionLabel": "v{version}", "networkTitle": "ネットワーク", "filterUrls": "URLをフィルター...", "copyUrl": "URLをコピー", @@ -297,5 +313,8 @@ "smoothScrolling": "スムーズスクロール", "smoothScrollingDesc": "マウスホイールのスクロールイベントに滑らかなアニメーションを追加します。ラグやパフォーマンス低下を感じる場合は、この設定をオフにしてください。", "smoothScrollingDuration": "スクロール時間", - "smoothScrollingDurationDesc": "スクロールアニメーションの時間(ミリ秒)。" + "smoothScrollingDurationDesc": "スクロールアニメーションの時間(ミリ秒)。", + "binaryBody": "{label}のボディはバイナリです", + "binaryBodySize": "{kb} KB ({bytes} バイト)", + "binaryBodyHint": "アクションは X-Amz-Target ヘッダーで識別してください。" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 38c1e0f..d72f82b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -843,6 +843,36 @@ abstract class S { /// **'Code mode exports as TypeScript / Dart / Kotlin based on the connected SDK.'** String get codeModeDesc; + /// No description provided for @treeModeDesc. + /// + /// In en, this message translates to: + /// **'Tree mode shows the data as an expandable, collapsible node hierarchy. Best for navigating deeply nested values.'** + String get treeModeDesc; + + /// No description provided for @jsonModeDesc. + /// + /// In en, this message translates to: + /// **'JSON mode renders the data as a single, syntax-highlighted, copy-friendly JSON document.'** + String get jsonModeDesc; + + /// No description provided for @captureDataJson. + /// + /// In en, this message translates to: + /// **'Capture data (key + value in current mode)'** + String get captureDataJson; + + /// No description provided for @captureDataText. + /// + /// In en, this message translates to: + /// **'Capture data (key + value as text)'** + String get captureDataText; + + /// No description provided for @copyKey. + /// + /// In en, this message translates to: + /// **'Copy key'** + String get copyKey; + /// No description provided for @usbConnection. /// /// In en, this message translates to: @@ -1047,6 +1077,72 @@ abstract class S { /// **'API calls will appear here in real-time'** String get apiCallsAppearHere; + /// No description provided for @clearStaleButton. + /// + /// In en, this message translates to: + /// **'Stale ({count})'** + String clearStaleButton(Object count); + + /// No description provided for @clearStaleTooltip. + /// + /// In en, this message translates to: + /// **'Clear {count} pending request(s) with no response for over 10 minutes'** + String clearStaleTooltip(Object count); + + /// No description provided for @clearStaleSnackbar. + /// + /// In en, this message translates to: + /// **'Cleared {count} stale request(s) (pending > 10min)'** + String clearStaleSnackbar(Object count); + + /// No description provided for @memorySafetyOverflow. + /// + /// In en, this message translates to: + /// **'Memory safety: dropped {count} stale network entry(ies) from the open-trips cache'** + String memorySafetyOverflow(Object count); + + /// No description provided for @sdkTipsPill. + /// + /// In en, this message translates to: + /// **'Tips'** + String get sdkTipsPill; + + /// No description provided for @sdkTipsHeader. + /// + /// In en, this message translates to: + /// **'Library compatibility'** + String get sdkTipsHeader; + + /// No description provided for @sdkTipsSubtitle. + /// + /// In en, this message translates to: + /// **'To ensure all data is fully displayed, make sure the libraries are the latest version.'** + String get sdkTipsSubtitle; + + /// No description provided for @sdkTipsFlutter. + /// + /// In en, this message translates to: + /// **'Flutter'** + String get sdkTipsFlutter; + + /// No description provided for @sdkTipsReactNative. + /// + /// In en, this message translates to: + /// **'React Native'** + String get sdkTipsReactNative; + + /// No description provided for @sdkTipsAndroid. + /// + /// In en, this message translates to: + /// **'Android'** + String get sdkTipsAndroid; + + /// No description provided for @sdkTipsVersionLabel. + /// + /// In en, this message translates to: + /// **'v{version}'** + String sdkTipsVersionLabel(Object version); + /// No description provided for @networkTitle. /// /// In en, this message translates to: @@ -1892,6 +1988,24 @@ abstract class S { /// In en, this message translates to: /// **'The duration of the smooth scroll animation in milliseconds.'** String get smoothScrollingDurationDesc; + + /// No description provided for @binaryBody. + /// + /// In en, this message translates to: + /// **'{label} body is binary'** + String binaryBody(String label); + + /// No description provided for @binaryBodySize. + /// + /// In en, this message translates to: + /// **'{kb} KB ({bytes} bytes)'** + String binaryBodySize(String kb, int bytes); + + /// No description provided for @binaryBodyHint. + /// + /// In en, this message translates to: + /// **'Identify the action via the X-Amz-Target header.'** + String get binaryBodyHint; } class _SDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b209fa5..7c189f3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -461,6 +461,23 @@ class SEn extends S { String get codeModeDesc => 'Code mode exports as TypeScript / Dart / Kotlin based on the connected SDK.'; + @override + String get treeModeDesc => + 'Tree mode shows the data as an expandable, collapsible node hierarchy. Best for navigating deeply nested values.'; + + @override + String get jsonModeDesc => + 'JSON mode renders the data as a single, syntax-highlighted, copy-friendly JSON document.'; + + @override + String get captureDataJson => 'Capture data (key + value in current mode)'; + + @override + String get captureDataText => 'Capture data (key + value as text)'; + + @override + String get copyKey => 'Copy key'; + @override String get usbConnection => 'USB Connection'; @@ -574,6 +591,50 @@ class SEn extends S { @override String get apiCallsAppearHere => 'API calls will appear here in real-time'; + @override + String clearStaleButton(Object count) { + return 'Stale ($count)'; + } + + @override + String clearStaleTooltip(Object count) { + return 'Clear $count pending request(s) with no response for over 10 minutes'; + } + + @override + String clearStaleSnackbar(Object count) { + return 'Cleared $count stale request(s) (pending > 10min)'; + } + + @override + String memorySafetyOverflow(Object count) { + return 'Memory safety: dropped $count stale network entry(ies) from the open-trips cache'; + } + + @override + String get sdkTipsPill => 'Tips'; + + @override + String get sdkTipsHeader => 'Library compatibility'; + + @override + String get sdkTipsSubtitle => + 'To ensure all data is fully displayed, make sure the libraries are the latest version.'; + + @override + String get sdkTipsFlutter => 'Flutter'; + + @override + String get sdkTipsReactNative => 'React Native'; + + @override + String get sdkTipsAndroid => 'Android'; + + @override + String sdkTipsVersionLabel(Object version) { + return 'v$version'; + } + @override String get networkTitle => 'Network'; @@ -1027,4 +1088,18 @@ class SEn extends S { @override String get smoothScrollingDurationDesc => 'The duration of the smooth scroll animation in milliseconds.'; + + @override + String binaryBody(String label) { + return '$label body is binary'; + } + + @override + String binaryBodySize(String kb, int bytes) { + return '$kb KB ($bytes bytes)'; + } + + @override + String get binaryBodyHint => + 'Identify the action via the X-Amz-Target header.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index ca3272f..c5df596 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -302,7 +302,7 @@ class SFr extends S { String get json => 'JSON'; @override - String get tree => 'Arborescence'; + String get tree => 'Tree'; @override String get code => 'Code'; @@ -461,6 +461,25 @@ class SFr extends S { String get codeModeDesc => 'Le mode code exporte en TypeScript / Dart / Kotlin selon le SDK connecté.'; + @override + String get treeModeDesc => + 'Le mode arborescence affiche les données sous forme de hiérarchie de nœuds dépliables. Idéal pour parcourir des valeurs profondément imbriquées.'; + + @override + String get jsonModeDesc => + 'Le mode JSON affiche les données sous forme de document JSON unique, coloré syntaxiquement et facile à copier.'; + + @override + String get captureDataJson => + 'Capturer les données (clé + valeur dans le mode actuel)'; + + @override + String get captureDataText => + 'Capturer les données (clé + valeur sous forme de texte)'; + + @override + String get copyKey => 'Copier la clé'; + @override String get usbConnection => 'Connexion USB'; @@ -575,6 +594,50 @@ class SFr extends S { String get apiCallsAppearHere => 'Les appels API apparaîtront ici en temps réel'; + @override + String clearStaleButton(Object count) { + return 'Obsolète ($count)'; + } + + @override + String clearStaleTooltip(Object count) { + return 'Supprimer $count requêtes en attente sans réponse depuis plus de 10 minutes'; + } + + @override + String clearStaleSnackbar(Object count) { + return '$count requête(s) obsolète(s) supprimée(s) (en attente > 10 min)'; + } + + @override + String memorySafetyOverflow(Object count) { + return 'Sécurité mémoire: $count entrée(s) réseau obsolète(s) supprimée(s) du cache open-trips'; + } + + @override + String get sdkTipsPill => 'Astuces'; + + @override + String get sdkTipsHeader => 'Compatibilité des bibliothèques'; + + @override + String get sdkTipsSubtitle => + 'Pour que toutes les données s\'affichent correctement, assurez-vous que les bibliothèques sont à la dernière version.'; + + @override + String get sdkTipsFlutter => 'Flutter'; + + @override + String get sdkTipsReactNative => 'React Native'; + + @override + String get sdkTipsAndroid => 'Android'; + + @override + String sdkTipsVersionLabel(Object version) { + return 'v$version'; + } + @override String get networkTitle => 'Réseau'; @@ -1029,4 +1092,18 @@ class SFr extends S { @override String get smoothScrollingDurationDesc => 'La durée de l\'animation de défilement en millisecondes.'; + + @override + String binaryBody(String label) { + return 'Le corps de $label est binaire'; + } + + @override + String binaryBodySize(String kb, int bytes) { + return '$kb Ko ($bytes octets)'; + } + + @override + String get binaryBodyHint => + 'Identifiez l\'action via l\'en-tête X-Amz-Target.'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 22d849f..f691362 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -302,10 +302,10 @@ class SJa extends S { String get json => 'JSON'; @override - String get tree => 'ツリー'; + String get tree => 'Tree'; @override - String get code => 'コード'; + String get code => 'Code'; @override String get raw => 'Raw'; @@ -459,6 +459,23 @@ class SJa extends S { String get codeModeDesc => 'コードモードは接続されたSDKに基づいてTypeScript / Dart / Kotlinとしてエクスポートします。'; + @override + String get treeModeDesc => + 'ツリーモードはデータを展開/折りたたみ可能なノード階層で表示します。深くネストされた値の閲覧に適しています。'; + + @override + String get jsonModeDesc => + 'JSONモードはデータを構文強調表示付きの単一のコピーしやすいJSONドキュメントとして表示します。'; + + @override + String get captureDataJson => 'データをキャプチャ (現在のモードのキー + 値)'; + + @override + String get captureDataText => 'データをキャプチャ (キー + 値をテキストとして)'; + + @override + String get copyKey => 'キーをコピー'; + @override String get usbConnection => 'USB接続'; @@ -571,6 +588,50 @@ class SJa extends S { @override String get apiCallsAppearHere => 'APIコールがリアルタイムでここに表示されます'; + @override + String clearStaleButton(Object count) { + return '古い ($count)'; + } + + @override + String clearStaleTooltip(Object count) { + return '10分以上応答のない保留中のリクエスト$count件を削除'; + } + + @override + String clearStaleSnackbar(Object count) { + return '古いリクエスト$count件を削除しました (保留中 > 10分)'; + } + + @override + String memorySafetyOverflow(Object count) { + return 'メモリ保護: open-trips キャッシュから古いネットワーク エントリ $count 件を削除しました'; + } + + @override + String get sdkTipsPill => 'ヒント'; + + @override + String get sdkTipsHeader => 'ライブラリの互換性'; + + @override + String get sdkTipsSubtitle => + 'すべてのデータが完全に表示されるよう、ライブラリが最新バージョンであることを確認してください。'; + + @override + String get sdkTipsFlutter => 'Flutter'; + + @override + String get sdkTipsReactNative => 'React Native'; + + @override + String get sdkTipsAndroid => 'Android'; + + @override + String sdkTipsVersionLabel(Object version) { + return 'v$version'; + } + @override String get networkTitle => 'ネットワーク'; @@ -1017,4 +1078,17 @@ class SJa extends S { @override String get smoothScrollingDurationDesc => 'スクロールアニメーションの時間(ミリ秒)。'; + + @override + String binaryBody(String label) { + return '$labelのボディはバイナリです'; + } + + @override + String binaryBodySize(String kb, int bytes) { + return '$kb KB ($bytes バイト)'; + } + + @override + String get binaryBodyHint => 'アクションは X-Amz-Target ヘッダーで識別してください。'; } diff --git a/lib/l10n/app_localizations_vi.dart b/lib/l10n/app_localizations_vi.dart index 9cf616b..28ce7fc 100644 --- a/lib/l10n/app_localizations_vi.dart +++ b/lib/l10n/app_localizations_vi.dart @@ -302,10 +302,10 @@ class SVi extends S { String get json => 'JSON'; @override - String get tree => 'Cây'; + String get tree => 'Tree'; @override - String get code => 'Mã'; + String get code => 'Code'; @override String get raw => 'Thô'; @@ -461,6 +461,24 @@ class SVi extends S { String get codeModeDesc => 'Chế độ mã xuất dưới dạng TypeScript / Dart / Kotlin dựa trên SDK đã kết nối.'; + @override + String get treeModeDesc => + 'Chế độ cây hiển thị dữ liệu dưới dạng cây nút có thể mở rộng/thu gọn. Phù hợp để duyệt các giá trị lồng nhau sâu.'; + + @override + String get jsonModeDesc => + 'Chế độ JSON hiển thị dữ liệu dưới dạng tài liệu JSON tô màu cú pháp, dễ sao chép.'; + + @override + String get captureDataJson => + 'Chụp ảnh dữ liệu (key + value theo chế độ hiện tại)'; + + @override + String get captureDataText => 'Chụp ảnh dữ liệu (key + value dạng text)'; + + @override + String get copyKey => 'Sao chép key'; + @override String get usbConnection => 'Kết nối USB'; @@ -575,6 +593,50 @@ class SVi extends S { String get apiCallsAppearHere => 'Các lệnh gọi API sẽ xuất hiện ở đây theo thời gian thực'; + @override + String clearStaleButton(Object count) { + return 'Cũ ($count)'; + } + + @override + String clearStaleTooltip(Object count) { + return 'Xóa $count yêu cầu đang chờ không có phản hồi trên 10 phút'; + } + + @override + String clearStaleSnackbar(Object count) { + return 'Đã xóa $count yêu cầu cũ (chờ > 10 phút)'; + } + + @override + String memorySafetyOverflow(Object count) { + return 'An toàn bộ nhớ: đã loại $count mục mạng cũ khỏi bộ nhớ đệm open-trips'; + } + + @override + String get sdkTipsPill => 'Mẹo'; + + @override + String get sdkTipsHeader => 'Tương thích thư viện'; + + @override + String get sdkTipsSubtitle => + 'Để đảm bảo mọi data được hiển thị đầy đủ, hãy đảm bảo thư viện đã là version mới nhất.'; + + @override + String get sdkTipsFlutter => 'Flutter'; + + @override + String get sdkTipsReactNative => 'React Native'; + + @override + String get sdkTipsAndroid => 'Android'; + + @override + String sdkTipsVersionLabel(Object version) { + return 'v$version'; + } + @override String get networkTitle => 'Mạng'; @@ -1030,4 +1092,17 @@ class SVi extends S { @override String get smoothScrollingDurationDesc => 'Thời gian chạy hiệu ứng cuộn mượt mà tính bằng mili-giây.'; + + @override + String binaryBody(String label) { + return 'Body $label là nhị phân'; + } + + @override + String binaryBodySize(String kb, int bytes) { + return '$kb KB ($bytes byte)'; + } + + @override + String get binaryBodyHint => 'Xác định action thông qua header X-Amz-Target.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 5492820..4529a6c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -298,10 +298,10 @@ class SZh extends S { String get json => 'JSON'; @override - String get tree => '树形'; + String get tree => 'Tree'; @override - String get code => '代码'; + String get code => 'Code'; @override String get raw => '原始'; @@ -453,6 +453,21 @@ class SZh extends S { @override String get codeModeDesc => '代码模式根据已连接的 SDK 导出为 TypeScript / Dart / Kotlin。'; + @override + String get treeModeDesc => '树形模式以可展开/折叠的节点层级显示数据,适合浏览深度嵌套的值。'; + + @override + String get jsonModeDesc => 'JSON 模式将数据呈现为单一、带语法高亮、易于复制的 JSON 文档。'; + + @override + String get captureDataJson => '截图数据(当前模式下的键 + 值)'; + + @override + String get captureDataText => '截图数据(键 + 值作为文本)'; + + @override + String get copyKey => '复制键'; + @override String get usbConnection => 'USB 连接'; @@ -564,6 +579,49 @@ class SZh extends S { @override String get apiCallsAppearHere => 'API 调用将实时显示在这里'; + @override + String clearStaleButton(Object count) { + return '过期 ($count)'; + } + + @override + String clearStaleTooltip(Object count) { + return '清除 $count 个超过 10 分钟未响应的挂起请求'; + } + + @override + String clearStaleSnackbar(Object count) { + return '已清除 $count 个过期请求 (挂起 > 10 分钟)'; + } + + @override + String memorySafetyOverflow(Object count) { + return '内存安全: 已从 open-trips 缓存中丢弃 $count 个过期网络条目'; + } + + @override + String get sdkTipsPill => '提示'; + + @override + String get sdkTipsHeader => '库兼容性'; + + @override + String get sdkTipsSubtitle => '为确保所有数据完整显示,请确保使用的是最新版本的库。'; + + @override + String get sdkTipsFlutter => 'Flutter'; + + @override + String get sdkTipsReactNative => 'React Native'; + + @override + String get sdkTipsAndroid => 'Android'; + + @override + String sdkTipsVersionLabel(Object version) { + return 'v$version'; + } + @override String get networkTitle => '网络'; @@ -1008,6 +1066,19 @@ class SZh extends S { @override String get smoothScrollingDurationDesc => '滚动动画的持续时间(毫秒)。'; + + @override + String binaryBody(String label) { + return '$label 正文为二进制'; + } + + @override + String binaryBodySize(String kb, int bytes) { + return '$kb KB ($bytes 字节)'; + } + + @override + String get binaryBodyHint => '通过 X-Amz-Target 请求头识别操作。'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -1304,10 +1375,10 @@ class SZhCn extends SZh { String get json => 'JSON'; @override - String get tree => '树形'; + String get tree => 'Tree'; @override - String get code => '代码'; + String get code => 'Code'; @override String get raw => '原始'; @@ -1459,6 +1530,15 @@ class SZhCn extends SZh { @override String get codeModeDesc => '代码模式根据已连接的 SDK 导出为 TypeScript / Dart / Kotlin。'; + @override + String get captureDataJson => '截图数据(当前模式下的键 + 值)'; + + @override + String get captureDataText => '截图数据(键 + 值作为文本)'; + + @override + String get copyKey => '复制键'; + @override String get usbConnection => 'USB 连接'; @@ -1570,6 +1650,49 @@ class SZhCn extends SZh { @override String get apiCallsAppearHere => 'API 调用将实时显示在这里'; + @override + String clearStaleButton(Object count) { + return '过期 ($count)'; + } + + @override + String clearStaleTooltip(Object count) { + return '清除 $count 个超过 10 分钟未响应的挂起请求'; + } + + @override + String clearStaleSnackbar(Object count) { + return '已清除 $count 个过期请求 (挂起 > 10 分钟)'; + } + + @override + String memorySafetyOverflow(Object count) { + return '内存安全: 已从 open-trips 缓存中丢弃 $count 个过期网络条目'; + } + + @override + String get sdkTipsPill => '提示'; + + @override + String get sdkTipsHeader => '库兼容性'; + + @override + String get sdkTipsSubtitle => '为确保所有数据完整显示,请确保使用的是最新版本的库。'; + + @override + String get sdkTipsFlutter => 'Flutter'; + + @override + String get sdkTipsReactNative => 'React Native'; + + @override + String get sdkTipsAndroid => 'Android'; + + @override + String sdkTipsVersionLabel(Object version) { + return 'v$version'; + } + @override String get networkTitle => '网络'; @@ -2014,6 +2137,19 @@ class SZhCn extends SZh { @override String get smoothScrollingDurationDesc => '滚动动画的持续时间(毫秒)。'; + + @override + String binaryBody(String label) { + return '$label 正文为二进制'; + } + + @override + String binaryBodySize(String kb, int bytes) { + return '$kb KB ($bytes 字节)'; + } + + @override + String get binaryBodyHint => '通过 X-Amz-Target 请求头识别操作。'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -2310,10 +2446,10 @@ class SZhTw extends SZh { String get json => 'JSON'; @override - String get tree => '樹狀'; + String get tree => 'Tree'; @override - String get code => '程式碼'; + String get code => 'Code'; @override String get raw => '原始'; @@ -2465,6 +2601,15 @@ class SZhTw extends SZh { @override String get codeModeDesc => '程式碼模式根據已連線的 SDK 匯出為 TypeScript / Dart / Kotlin。'; + @override + String get captureDataJson => '擷取資料 (目前模式的金鑰 + 值)'; + + @override + String get captureDataText => '擷取資料 (金鑰 + 值作為文字)'; + + @override + String get copyKey => '複製金鑰'; + @override String get usbConnection => 'USB 連線'; @@ -2576,6 +2721,49 @@ class SZhTw extends SZh { @override String get apiCallsAppearHere => 'API 呼叫將即時顯示在這裡'; + @override + String clearStaleButton(Object count) { + return '過期 ($count)'; + } + + @override + String clearStaleTooltip(Object count) { + return '清除 $count 個超過 10 分鐘未回應的掛起請求'; + } + + @override + String clearStaleSnackbar(Object count) { + return '已清除 $count 個過期請求 (掛起 > 10 分鐘)'; + } + + @override + String memorySafetyOverflow(Object count) { + return '記憶體安全: 已從 open-trips 快取中丟棄 $count 個過期網路條目'; + } + + @override + String get sdkTipsPill => '提示'; + + @override + String get sdkTipsHeader => '庫相容性'; + + @override + String get sdkTipsSubtitle => '為確保所有資料完整顯示,請確保使用的是最新版本的庫。'; + + @override + String get sdkTipsFlutter => 'Flutter'; + + @override + String get sdkTipsReactNative => 'React Native'; + + @override + String get sdkTipsAndroid => 'Android'; + + @override + String sdkTipsVersionLabel(Object version) { + return 'v$version'; + } + @override String get networkTitle => '網路'; @@ -3020,4 +3208,17 @@ class SZhTw extends SZh { @override String get smoothScrollingDurationDesc => '滾動動畫的持續時間(毫秒)。'; + + @override + String binaryBody(String label) { + return '$label 內文為二進位'; + } + + @override + String binaryBodySize(String kb, int bytes) { + return '$kb KB ($bytes 位元組)'; + } + + @override + String get binaryBodyHint => '透過 X-Amz-Target 標頭識別操作。'; } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index efc8ec6..ec9078e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -78,8 +78,8 @@ "duration": "Thời lượng", "error": "Lỗi", "json": "JSON", - "tree": "Cây", - "code": "Mã", + "tree": "Tree", + "code": "Code", "raw": "Thô", "format": "Định dạng", "pretty": "Đẹp", @@ -123,6 +123,11 @@ "tabAnimation": "Hoạt ảnh tab", "tabAnimationDuration": "Thời lượng", "codeModeDesc": "Chế độ mã xuất dưới dạng TypeScript / Dart / Kotlin dựa trên SDK đã kết nối.", + "captureDataJson": "Chụp ảnh dữ liệu (key + value theo chế độ hiện tại)", + "captureDataText": "Chụp ảnh dữ liệu (key + value dạng text)", + "copyKey": "Sao chép key", + "treeModeDesc": "Chế độ cây hiển thị dữ liệu dưới dạng cây nút có thể mở rộng/thu gọn. Phù hợp để duyệt các giá trị lồng nhau sâu.", + "jsonModeDesc": "Chế độ JSON hiển thị dữ liệu dưới dạng tài liệu JSON tô màu cú pháp, dễ sao chép.", "usbConnection": "Kết nối USB", "android": "Android", "ios": "iOS", @@ -157,6 +162,17 @@ "history": "Lịch sử", "noNetworkRequests": "Không có yêu cầu mạng", "apiCallsAppearHere": "Các lệnh gọi API sẽ xuất hiện ở đây theo thời gian thực", + "clearStaleButton": "Cũ ({count})", + "clearStaleTooltip": "Xóa {count} yêu cầu đang chờ không có phản hồi trên 10 phút", + "clearStaleSnackbar": "Đã xóa {count} yêu cầu cũ (chờ > 10 phút)", + "memorySafetyOverflow": "An toàn bộ nhớ: đã loại {count} mục mạng cũ khỏi bộ nhớ đệm open-trips", + "sdkTipsPill": "Mẹo", + "sdkTipsHeader": "Tương thích thư viện", + "sdkTipsSubtitle": "Để đảm bảo mọi data được hiển thị đầy đủ, hãy đảm bảo thư viện đã là version mới nhất.", + "sdkTipsFlutter": "Flutter", + "sdkTipsReactNative": "React Native", + "sdkTipsAndroid": "Android", + "sdkTipsVersionLabel": "v{version}", "networkTitle": "Mạng", "filterUrls": "Lọc URL...", "copyUrl": "Sao chép URL", @@ -297,5 +313,8 @@ "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.", "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." + "smoothScrollingDurationDesc": "Thời gian chạy hiệu ứng cuộn mượt mà tính bằng mili-giây.", + "binaryBody": "Body {label} là nhị phân", + "binaryBodySize": "{kb} KB ({bytes} byte)", + "binaryBodyHint": "Xác định action thông qua header X-Amz-Target." } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1a4aee3..a3f0286 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -78,8 +78,8 @@ "duration": "持续时间", "error": "错误", "json": "JSON", - "tree": "树形", - "code": "代码", + "tree": "Tree", + "code": "Code", "raw": "原始", "format": "格式", "pretty": "美化", @@ -123,6 +123,11 @@ "tabAnimation": "标签页动画", "tabAnimationDuration": "持续时间", "codeModeDesc": "代码模式根据已连接的 SDK 导出为 TypeScript / Dart / Kotlin。", + "captureDataJson": "截图数据(当前模式下的键 + 值)", + "captureDataText": "截图数据(键 + 值作为文本)", + "copyKey": "复制键", + "treeModeDesc": "树形模式以可展开/折叠的节点层级显示数据,适合浏览深度嵌套的值。", + "jsonModeDesc": "JSON 模式将数据呈现为单一、带语法高亮、易于复制的 JSON 文档。", "usbConnection": "USB 连接", "android": "Android", "ios": "iOS", @@ -157,6 +162,17 @@ "history": "历史记录", "noNetworkRequests": "无网络请求", "apiCallsAppearHere": "API 调用将实时显示在这里", + "clearStaleButton": "过期 ({count})", + "clearStaleTooltip": "清除 {count} 个超过 10 分钟未响应的挂起请求", + "clearStaleSnackbar": "已清除 {count} 个过期请求 (挂起 > 10 分钟)", + "memorySafetyOverflow": "内存安全: 已从 open-trips 缓存中丢弃 {count} 个过期网络条目", + "sdkTipsPill": "提示", + "sdkTipsHeader": "库兼容性", + "sdkTipsSubtitle": "为确保所有数据完整显示,请确保使用的是最新版本的库。", + "sdkTipsFlutter": "Flutter", + "sdkTipsReactNative": "React Native", + "sdkTipsAndroid": "Android", + "sdkTipsVersionLabel": "v{version}", "networkTitle": "网络", "filterUrls": "筛选 URL...", "copyUrl": "复制 URL", @@ -297,5 +313,8 @@ "smoothScrolling": "平滑滚动", "smoothScrollingDesc": "为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。", "smoothScrollingDuration": "滚动持续时间", - "smoothScrollingDurationDesc": "滚动动画的持续时间(毫秒)。" + "smoothScrollingDurationDesc": "滚动动画的持续时间(毫秒)。", + "binaryBody": "{label} 正文为二进制", + "binaryBodySize": "{kb} KB ({bytes} 字节)", + "binaryBodyHint": "通过 X-Amz-Target 请求头识别操作。" } \ No newline at end of file diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb index 2527cca..e85f586 100644 --- a/lib/l10n/app_zh_CN.arb +++ b/lib/l10n/app_zh_CN.arb @@ -78,8 +78,8 @@ "duration": "持续时间", "error": "错误", "json": "JSON", - "tree": "树形", - "code": "代码", + "tree": "Tree", + "code": "Code", "raw": "原始", "format": "格式", "pretty": "美化", @@ -123,6 +123,9 @@ "tabAnimation": "标签页动画", "tabAnimationDuration": "持续时间", "codeModeDesc": "代码模式根据已连接的 SDK 导出为 TypeScript / Dart / Kotlin。", + "captureDataJson": "截图数据(当前模式下的键 + 值)", + "captureDataText": "截图数据(键 + 值作为文本)", + "copyKey": "复制键", "usbConnection": "USB 连接", "android": "Android", "ios": "iOS", @@ -157,6 +160,19 @@ "history": "历史记录", "noNetworkRequests": "无网络请求", "apiCallsAppearHere": "API 调用将实时显示在这里", + "noNetworkRequests": "无网络请求", + "apiCallsAppearHere": "API 调用将实时显示在这里", + "clearStaleButton": "过期 ({count})", + "clearStaleTooltip": "清除 {count} 个超过 10 分钟未响应的挂起请求", + "clearStaleSnackbar": "已清除 {count} 个过期请求 (挂起 > 10 分钟)", + "memorySafetyOverflow": "内存安全: 已从 open-trips 缓存中丢弃 {count} 个过期网络条目", + "sdkTipsPill": "提示", + "sdkTipsHeader": "库兼容性", + "sdkTipsSubtitle": "为确保所有数据完整显示,请确保使用的是最新版本的库。", + "sdkTipsFlutter": "Flutter", + "sdkTipsReactNative": "React Native", + "sdkTipsAndroid": "Android", + "sdkTipsVersionLabel": "v{version}", "networkTitle": "网络", "filterUrls": "筛选 URL...", "copyUrl": "复制 URL", @@ -297,5 +313,8 @@ "smoothScrolling": "平滑滚动", "smoothScrollingDesc": "为鼠标滚轮事件添加平滑动画。如果遇到卡顿或性能下降,请关闭此选项。", "smoothScrollingDuration": "滚动持续时间", - "smoothScrollingDurationDesc": "滚动动画的持续时间(毫秒)。" + "smoothScrollingDurationDesc": "滚动动画的持续时间(毫秒)。", + "binaryBody": "{label} 正文为二进制", + "binaryBodySize": "{kb} KB ({bytes} 字节)", + "binaryBodyHint": "通过 X-Amz-Target 请求头识别操作。" } \ No newline at end of file diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index d6946ba..df9e436 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -78,8 +78,8 @@ "duration": "持續時間", "error": "錯誤", "json": "JSON", - "tree": "樹狀", - "code": "程式碼", + "tree": "Tree", + "code": "Code", "raw": "原始", "format": "格式", "pretty": "美化", @@ -123,6 +123,9 @@ "tabAnimation": "分頁動畫", "tabAnimationDuration": "持續時間", "codeModeDesc": "程式碼模式根據已連線的 SDK 匯出為 TypeScript / Dart / Kotlin。", + "captureDataJson": "擷取資料 (目前模式的金鑰 + 值)", + "captureDataText": "擷取資料 (金鑰 + 值作為文字)", + "copyKey": "複製金鑰", "usbConnection": "USB 連線", "android": "Android", "ios": "iOS", @@ -157,6 +160,19 @@ "history": "歷史記錄", "noNetworkRequests": "無網路請求", "apiCallsAppearHere": "API 呼叫將即時顯示在這裡", + "noNetworkRequests": "無網路請求", + "apiCallsAppearHere": "API 呼叫將即時顯示在這裡", + "clearStaleButton": "過期 ({count})", + "clearStaleTooltip": "清除 {count} 個超過 10 分鐘未回應的掛起請求", + "clearStaleSnackbar": "已清除 {count} 個過期請求 (掛起 > 10 分鐘)", + "memorySafetyOverflow": "記憶體安全: 已從 open-trips 快取中丟棄 {count} 個過期網路條目", + "sdkTipsPill": "提示", + "sdkTipsHeader": "庫相容性", + "sdkTipsSubtitle": "為確保所有資料完整顯示,請確保使用的是最新版本的庫。", + "sdkTipsFlutter": "Flutter", + "sdkTipsReactNative": "React Native", + "sdkTipsAndroid": "Android", + "sdkTipsVersionLabel": "v{version}", "networkTitle": "網路", "filterUrls": "篩選 URL...", "copyUrl": "複製 URL", @@ -297,5 +313,8 @@ "smoothScrolling": "平滑捲動", "smoothScrollingDesc": "為滑鼠滾輪事件添加平滑動畫。如果遇到卡頓或性能下降,請關閉此選項。", "smoothScrollingDuration": "滾動持續時間", - "smoothScrollingDurationDesc": "滾動動畫的持續時間(毫秒)。" + "smoothScrollingDurationDesc": "滾動動畫的持續時間(毫秒)。", + "binaryBody": "{label} 內文為二進位", + "binaryBodySize": "{kb} KB ({bytes} 位元組)", + "binaryBodyHint": "透過 X-Amz-Target 標頭識別操作。" } \ No newline at end of file diff --git a/lib/l10n/untranslated.txt b/lib/l10n/untranslated.txt index 9e26dfe..63a8bdf 100644 --- a/lib/l10n/untranslated.txt +++ b/lib/l10n/untranslated.txt @@ -1 +1,11 @@ -{} \ No newline at end of file +{ + "zh_CN": [ + "treeModeDesc", + "jsonModeDesc" + ], + + "zh_TW": [ + "treeModeDesc", + "jsonModeDesc" + ] +} diff --git a/lib/models/network/network_entry.dart b/lib/models/network/network_entry.dart index f1d2b08..ecee59d 100644 --- a/lib/models/network/network_entry.dart +++ b/lib/models/network/network_entry.dart @@ -20,7 +20,9 @@ abstract class NetworkEntry with _$NetworkEntry { int? duration, String? error, @Default(false) bool isComplete, - @Default('app') String source, // 'app', 'library', 'system' + @Default('app') String source, + String? serviceName, + String? serviceAction, }) = _NetworkEntry; factory NetworkEntry.fromJson(Map json) => diff --git a/lib/server/ws_message_handler.dart b/lib/server/ws_message_handler.dart index d237557..a3b55e7 100644 --- a/lib/server/ws_message_handler.dart +++ b/lib/server/ws_message_handler.dart @@ -1,6 +1,8 @@ import 'dart:async'; import '../core/constants/ws_constants.dart'; +import '../core/utils/network_service_detector.dart'; +import '../core/utils/network_url_utils.dart'; import '../models/device_info.dart'; import '../models/log/log_entry.dart'; import '../models/log/error_event.dart'; @@ -11,6 +13,7 @@ import '../models/performance/performance_entry.dart'; import '../models/storage/storage_entry.dart'; import 'protocol/dc_message.dart'; import 'ws_server.dart'; +import 'package:uuid/uuid.dart'; class WsMessageHandler { final WsServer server; @@ -49,6 +52,133 @@ class WsMessageHandler { late final StreamSubscription _connectionSub; late final StreamSubscription _disconnectionSub; + /// State for an open round-trip (start seen, complete still pending). + /// All messages — start, complete, success, error — of a single + /// logical request share the same canonical id. + /// + /// Key = canonical id (which we mint once per round-trip). + /// Value = the bare `requestId` so we can look up "is this requestId + /// currently busy with another open round-trip?" when a new message + /// arrives. + final _openTrips = {}; // canonicalId -> base requestId + int _networkSeq = 0; + final _uuid = const Uuid(); + + /// Tracks ids we've already emitted for one-shot entries (log, state, + /// storage, performance, display, async, error). If a client reuses + /// the same `message.id` across two messages — e.g. a retried log or + /// a state snapshot sent twice — we disambiguate so the row in the + /// UI list stays a distinct entry. + final _seenMessageIds = {}; + int _genericSeq = 0; + + /// Build a unique id for one logical network request. + /// + /// One round-trip = one start + one complete (whether success or + /// error) sharing a `requestId`. All messages of that round-trip + /// must emit the SAME id so the provider can merge them into a + /// single row. + /// + /// When two genuinely concurrent requests arrive with the same + /// `requestId`, the first start mints `base` as the canonical id and + /// marks it open; the second start sees the open trip and mints a + /// fresh disambiguated id (also marked open). Each round-trip's + /// complete then finds its own canonical id via the open-trips map. + String _uniqueNetworkId(DCMessage message, Map payload) { + final raw = payload['requestId'] as String?; + final base = (raw != null && raw.isNotEmpty) ? raw : message.id; + final isComplete = + message.type == WsMessageTypes.clientNetworkRequestComplete; + final canonical = _mintOrReuseCanonical(base, isComplete); + if (!isComplete) { + // Start — register the round-trip as open so the matching + // complete can find it. Also remember the base for dedup. + _openTrips[canonical] = base; + } else { + // Complete — drop the open-trip entry. If no open trip existed + // (orphan complete or disambiguated start whose complete we + // also disambiguated), nothing to remove. + _openTrips.remove(canonical); + } + _trimOpenTrips(); + return canonical; + } + + /// For a start: if no round-trip is currently open for this base, + /// mint a fresh canonical id and remember it as open. If another + /// round-trip is already open for the same base, disambiguate and + /// remember a fresh id. + /// + /// For a complete: locate the open round-trip for this base and + /// reuse its canonical id (this is the start→complete round-trip + /// case). If no open trip exists (orphan complete), mint a fresh + /// canonical id and don't open a trip (it'll just stand alone). + String _mintOrReuseCanonical(String base, bool isComplete) { + if (!isComplete) { + // Find any existing open trip for this base. + final existing = _existingOpenCanonicalForBase(base); + if (existing != null && _isOpenFor(existing)) { + return _disambiguate(base); + } + return base; + } + // Complete: find the open round-trip for this base. + final existing = _existingOpenCanonicalForBase(base); + if (existing != null) { + return existing; + } + return base; + } + + String? _existingOpenCanonicalForBase(String base) { + for (final entry in _openTrips.entries) { + if (entry.value == base) return entry.key; + } + return null; + } + + bool _isOpenFor(String canonical) => _openTrips.containsKey(canonical); + + String _disambiguate(String base) { + final seq = (++_networkSeq).toRadixString(36); + final micros = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + final rand = _uuid.v4().substring(0, 4); + return '$base-$micros-$seq-$rand'; + } + + void _trimOpenTrips() { + if (_openTrips.length <= 2048) return; + final drop = _openTrips.length - 1024; + final keys = _openTrips.keys.toList(growable: false); + for (var i = 0; i < drop; i++) { + _openTrips.remove(keys[i]); + } + } + + /// Mint a unique id for a one-shot entry (log, state, storage, etc.). + /// Unlike network round-trips these don't have a `start`/`complete` + /// pair, so we just guarantee that no two entries ever share the + /// same id — if `message.id` was already seen, disambiguate. + String _uniqueOneShotId(String messageId) { + if (_seenMessageIds.add(messageId)) return messageId; + final seq = (++_genericSeq).toRadixString(36); + final micros = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + final rand = _uuid.v4().substring(0, 4); + final newId = '$messageId-$micros-$seq-$rand'; + _seenMessageIds.add(newId); + _trimSeenMessageIds(); + return newId; + } + + void _trimSeenMessageIds() { + if (_seenMessageIds.length <= 4096) return; + final drop = _seenMessageIds.length - 2048; + final keys = _seenMessageIds.toList(growable: false); + for (var i = 0; i < drop; i++) { + _seenMessageIds.remove(keys[i]); + } + } + WsMessageHandler({required this.server}) { _messageSub = server.onMessage.listen(_handleMessage); _connectionSub = server.onConnection.listen((device) => _deviceController.add(device)); @@ -191,7 +321,7 @@ class WsMessageHandler { void _handleLog(DCMessage message) { final entry = LogEntry( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, level: _parseLogLevel(message.payload['level'] as String? ?? 'info'), message: message.payload['message'] as String? ?? '', @@ -205,22 +335,31 @@ class WsMessageHandler { void _handleNetwork(DCMessage message) { final p = message.payload; + final reqHeaders = _castStringMap(p['requestHeaders']); + final resHeaders = _castStringMap(p['responseHeaders']); + final reqBody = p['requestBody']; + final resBody = p['responseBody']; + final url = normalizeNetworkUrl(p['url'] as String?); + final detected = detectService(url, + headers: {...reqHeaders, ...resHeaders}, body: reqBody); final entry = NetworkEntry( - id: p['requestId'] as String? ?? message.id, + id: _uniqueNetworkId(message, p), deviceId: message.deviceId, method: p['method'] as String? ?? 'GET', - url: p['url'] as String? ?? '', + url: url, statusCode: p['statusCode'] as int? ?? 0, - requestHeaders: _castStringMap(p['requestHeaders']), - responseHeaders: _castStringMap(p['responseHeaders']), - requestBody: p['requestBody'], - responseBody: p['responseBody'], + requestHeaders: reqHeaders, + responseHeaders: resHeaders, + requestBody: reqBody, + responseBody: resBody, startTime: p['startTime'] as int? ?? message.timestamp, endTime: p['endTime'] as int?, duration: p['duration'] as int?, error: p['error'] as String?, isComplete: message.type == WsMessageTypes.clientNetworkRequestComplete, source: p['source'] as String? ?? 'app', + serviceName: detected?.name, + serviceAction: detected?.action, ); _networkController.add(entry); } @@ -236,7 +375,7 @@ class WsMessageHandler { []; final entry = StateChange( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, stateManagerType: p['stateManager'] as String? ?? 'unknown', actionName: p['action'] as String? ?? '', @@ -252,7 +391,7 @@ class WsMessageHandler { void _handleStorage(DCMessage message) { final p = message.payload; final entry = StorageEntry( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, storageType: _parseStorageType(p['storageType'] as String? ?? ''), key: p['key'] as String? ?? '', @@ -319,7 +458,7 @@ class WsMessageHandler { void _handlePerformance(DCMessage message) { final p = message.payload; final entry = PerformanceEntry( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, metricType: _parseMetricType(p['metricType'] as String? ?? 'fps'), value: (p['value'] as num?)?.toDouble() ?? 0.0, @@ -332,7 +471,7 @@ class WsMessageHandler { void _handleMemoryLeak(DCMessage message) { final p = message.payload; final entry = MemoryLeakEntry( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, leakType: _parseLeakType(p['leakType'] as String? ?? 'custom'), objectName: p['objectName'] as String? ?? '', @@ -383,7 +522,7 @@ class WsMessageHandler { void _handleDisplay(DCMessage message) { final p = message.payload; final entry = DisplayEntry( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, name: p['name'] as String? ?? 'Display', timestamp: message.timestamp, @@ -398,7 +537,7 @@ class WsMessageHandler { void _handleAsyncOperation(DCMessage message) { final p = message.payload; final entry = AsyncOperationEntry( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, operationType: _parseAsyncOpType(p['operationType'] as String? ?? 'custom'), description: p['description'] as String? ?? '', @@ -416,7 +555,7 @@ class WsMessageHandler { void _handleError(DCMessage message) { final p = message.payload; final entry = ErrorEvent( - id: message.id, + id: _uniqueOneShotId(message.id), deviceId: message.deviceId, platform: _parseErrorPlatform(p['platform'] as String? ?? 'js'), severity: _parseErrorSeverity(p['severity'] as String? ?? 'error'),