Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/pr-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,21 @@ jobs:
with:
channel: stable
- run: flutter pub get
- run: flutter analyze --no-fatal-infos
- run: dart run build_runner build --delete-conflicting-outputs
- name: Block print/debugPrint in lib/
run: |
set -e
hits=$(grep -rEn '\b(print|debugPrint)\(' lib/ \
--include='*.dart' \
--exclude='*.freezed.dart' \
--exclude='*.g.dart' || true)
if [ -n "$hits" ]; then
echo "❌ print()/debugPrint() found in lib/:"
echo "$hits"
exit 1
fi
echo "✓ No print()/debugPrint() in lib/"
- run: flutter analyze --no-fatal-infos lib/

build-macos:
name: Build macOS
Expand All @@ -29,6 +43,7 @@ jobs:
with:
channel: stable
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- run: flutter build macos --release

build-windows:
Expand All @@ -40,6 +55,7 @@ jobs:
with:
channel: stable
- run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- run: flutter build windows --release

sdk-check:
Expand Down
107 changes: 93 additions & 14 deletions client_sdks/devconnect-react-native/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,14 @@ export class DevConnect {
private originalFetch: typeof global.fetch | null = null;
private originalXHR: typeof global.XMLHttpRequest | null = null;
private originalConsole: { log: Function; warn: Function; error: Function; debug: Function; info: Function; trace?: Function } | null = null;
/**
* Map of `METHOD\0URL` keys currently in flight through the fetch
* interceptor to the number of active concurrent requests. The XHR
* interceptor (which React Native 0.85's fetch transport triggers
* internally for the same call) checks this map and suppresses its
* own duplicate report if the count is > 0.
*/
private fetchInFlight: Map<string, number> = new Map();

private constructor(config: DevConnectConfig & { resolvedHost: string }) {
this.config = {
Expand Down Expand Up @@ -536,21 +544,31 @@ export class DevConnect {
return dc;
}

const port = config.port ?? 9090;
const shouldAuto = (config.auto ?? true) && (!config.host || config.host === 'auto');

const resolvedHost = shouldAuto
? await autoDetectHost(port)
: (config.host ?? 'localhost');

const dc = new DevConnect({ ...config, resolvedHost });
// Create instance immediately with placeholder host so we can patch synchronously
const dc = new DevConnect({ ...config, resolvedHost: 'localhost' });
DevConnect.instance = dc;

dc.connect();
// Patch interceptors synchronously so no early network requests or logs are missed
if (dc.config.autoInterceptFetch) dc.patchFetch();
if (dc.config.autoInterceptXHR) dc.patchXHR();
if (dc.config.autoInterceptConsole) dc.patchConsole();

// Resolve real host and connect asynchronously in the background
const port = config.port ?? 9090;
const shouldAuto = (config.auto ?? true) && (!config.host || config.host === 'auto');

if (shouldAuto) {
autoDetectHost(port).then((resolvedHost) => {
dc.config.host = resolvedHost;
dc.connect();
}).catch(() => {
dc.connect();
});
} else {
dc.config.host = config.host ?? 'localhost';
dc.connect();
}

// Auto-start monitoring plugins
// Using dynamic require() to avoid circular dependency at module load time
try {
Expand Down Expand Up @@ -896,7 +914,11 @@ export class DevConnect {
}

const source = classifyUrl(url);
dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source });
// Track this fetch so the XHR interceptor (which fires on RN's
// internal XHR transport for the same call) can dedup against us.
const fetchKey = `${method}\0${url}`;
dc.fetchInFlight.set(fetchKey, (dc.fetchInFlight.get(fetchKey) ?? 0) + 1);
dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source, via: 'fetch' });

try {
const response = await originalFetch(input, init);
Expand All @@ -910,14 +932,28 @@ export class DevConnect {
requestId, method, url, statusCode: response.status, startTime,
endTime: Date.now(), duration: Date.now() - startTime,
requestHeaders: reqHeaders, responseHeaders: resHeaders, requestBody, responseBody, source,
via: 'fetch',
});
const currentCount = dc.fetchInFlight.get(fetchKey) ?? 0;
if (currentCount <= 1) {
dc.fetchInFlight.delete(fetchKey);
} else {
dc.fetchInFlight.set(fetchKey, currentCount - 1);
}
return response;
} catch (error: any) {
dc.send('client:network:request_complete', {
requestId, method, url, statusCode: 0, startTime,
endTime: Date.now(), duration: Date.now() - startTime,
requestHeaders: reqHeaders, requestBody, error: error?.message ?? String(error), source,
via: 'fetch',
});
const currentCount = dc.fetchInFlight.get(fetchKey) ?? 0;
if (currentCount <= 1) {
dc.fetchInFlight.delete(fetchKey);
} else {
dc.fetchInFlight.set(fetchKey, currentCount - 1);
}
throw error;
}
};
Expand Down Expand Up @@ -967,29 +1003,72 @@ export class DevConnect {
try { requestBody = JSON.parse(body); } catch (_) { requestBody = body; }
}
}
dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source: classifyUrl(url) });
// Skip the start report if the fetch interceptor already
// covers this call (see handleLoadEnd for the matching skip).
const xhrKey = `${method}\0${url}`;
const isFetchRequest = (dc.fetchInFlight.get(xhrKey) ?? 0) > 0;
if (!isFetchRequest) {
dc.send('client:network:request_start', { requestId, method, url, startTime, requestHeaders: reqHeaders, requestBody, source: classifyUrl(url), via: 'xhr' });
}
return origSend(body);
};

const handleLoadEnd = () => {
const handleLoadEnd = async () => {
xhr.removeEventListener('loadend', handleLoadEnd);
// Skip if the fetch interceptor already reported this call —
// RN 0.85's fetch goes through XHR internally, so without this
// every fetch would double-fire (once via fetch path, once via
// XHR path) with different requestIds, which the server
// cannot merge downstream.
const xhrKey = `${method}\0${url}`;
const isFetchRequest = (dc.fetchInFlight.get(xhrKey) ?? 0) > 0;
if (isFetchRequest) {
return;
}
const resHeaders: Record<string, string> = {};
try { xhr.getAllResponseHeaders().split('\r\n').forEach((l: string) => { const i = l.indexOf(':'); if (i > 0) resHeaders[l.substring(0, i).trim()] = l.substring(i + 1).trim(); }); } catch (_) {}
let responseBody: any;
// Only read responseText if responseType allows it (not blob/arraybuffer)
const rt = xhr.responseType;
if (!rt || rt === 'text' || (rt as string) === '') {
try { responseBody = JSON.parse(xhr.responseText); } catch (_) { responseBody = xhr.responseText; }
} else if (rt === 'json') {
responseBody = xhr.response;
} else {
responseBody = `<${rt} ${xhr.response?.size ?? xhr.response?.byteLength ?? '?'} bytes>`;
// Non-text responseType (blob, arraybuffer, ...). If the
// server's Content-Type looks like JSON or text, try to read
// it as text — saves the user from seeing `<blob 2852 bytes>`
// when the server actually sent JSON. No size cap: a mis-set
// responseType on a JSON API response can be arbitrarily
// large, and we trust the Content-Type to tell us when the
// body is genuinely binary (image/*, video/*, audio/*,
// application/octet-stream, ...).
const ct = (resHeaders['content-type'] ?? '').toLowerCase();
const isJsonCt = ct.includes('json') || ct.includes('+json');
const isTextCt = ct.startsWith('text/') || ct === 'application/javascript' || ct === 'application/x-www-form-urlencoded';
const blob = xhr.response as any;
const blobSize = blob?.size ?? blob?.byteLength ?? 0;
if ((isJsonCt || isTextCt) && blob && typeof blob.text === 'function') {
try {
const text = await blob.text();
if (isJsonCt) {
try { responseBody = JSON.parse(text); }
catch (_) { responseBody = text; }
} else {
responseBody = text;
}
} catch (_) {
responseBody = `<${rt} ${blobSize || '?'} bytes>`;
}
} else {
responseBody = `<${rt} ${blobSize || xhr.response?.byteLength || '?'} bytes>`;
}
}
dc.send('client:network:request_complete', {
requestId, method, url, statusCode: xhr.status, startTime,
endTime: Date.now(), duration: Date.now() - startTime,
requestHeaders: reqHeaders, responseHeaders: resHeaders, requestBody, responseBody,
source: classifyUrl(url),
via: 'xhr',
...(xhr.status === 0 ? { error: 'Network request failed' } : {}),
});
};
Expand Down
77 changes: 77 additions & 0 deletions lib/components/misc/retention_hint.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';

import '../../core/constants/app_constants.dart';
import '../../core/theme/color_tokens.dart';

/// Count pill + optional "Showing N of M" hint used by per-feature
/// toolbars. Mirrors the pattern from the All Events header so the UX
/// stays consistent across pages.
///
/// - Pill always shows `count` and, when [limit] is set, the cap label
/// (e.g. `87 / 100`).
/// - When [total] > [count] (i.e. the source list was longer than the
/// cap and oldest entries were dropped), a small note `Showing N of M`
/// is rendered below the pill in muted grey so the user knows older
/// entries are hidden.
class RetentionHint extends StatelessWidget {
/// Visible entry count after capping.
final int count;

/// Source list length BEFORE capping. When > [count], a "Showing N
/// of M" note is rendered.
final int total;

/// User-configured retention cap. `null` = no cap; pill shows just
/// `count` and the note is hidden (nothing is being trimmed).
final int? limit;

/// Human label for the cap (e.g. `100`, `1K`, `Unlimited`).
final String limitLabel;

const RetentionHint({
super.key,
required this.count,
required this.total,
required this.limit,
required this.limitLabel,
});

@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isTrimmed = limit != null && total > count;

return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: ColorTokens.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Text(
limit == null ? '$count' : '$count / $limitLabel',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: ColorTokens.primary,
),
),
),
if (isTrimmed) ...[
const SizedBox(height: 2),
Text(
'Showing $count of $total',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Localize the "Showing N of M" string.

The Showing $count of $total text is user-facing but hardcoded in English. This PR adds localized strings for dataRetention, allEventsDisplay, and other retention-related labels across all supported locales, but this hint text is missed. Non-English users will see an English fragment in an otherwise localized UI.

🌐 Proposed fix to localize the hint

Add a new ARB key (e.g. showingCountOfTotal) with a placeholder:

+  "showingCountOfTotal": "Showing {count} of {total}",
+  "`@showingCountOfTotal`": {
+    "placeholders": {
+      "count": { "type": "int" },
+      "total": { "type": "int" }
+    }
+  },

Then use it in the widget:

-          Text(
-            'Showing $count of $total',
+          Text(
+            S.of(context).showingCountOfTotal(count, total),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'Showing $count of $total',
Text(
S.of(context).showingCountOfTotal(count, total),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/components/misc/retention_hint.dart` at line 66, Add a localized
`showingCountOfTotal` ARB key with count and total placeholders in every
supported locale, then update the retention hint widget to use the generated
localization accessor instead of the hardcoded `Showing $count of $total`
string.

style: TextStyle(
fontSize: 9,
fontFamily: AppConstants.monoFontFamily,
color: isDark ? Colors.grey[600] : Colors.grey[500],
),
),
],
],
);
}
}
Loading
Loading