shake_context 0.2.0
shake_context: ^0.2.0 copied to clipboard
Dual-mode, privacy-first, shake-triggered bug reporting and user feedback engine for Flutter.
shake_context #
A dual-mode, privacy-first, shake-triggered bug reporting and user feedback engine for Flutter.
One widget. Two completely different surfaces depending on the build channel: a high-density diagnostic dashboard for engineers and QA, and a privacy-respecting feedback sheet for production consumers.
Why two modes? #
| Aspect | Developer Mode (InspectMode.developer) |
Production Mode (InspectMode.production) |
|---|---|---|
| Shake trigger | Auto-on in debug / profile builds | Honors an explicit user toggle |
| Screenshot | Captured automatically | Optional — user can remove it |
| Telemetry | Route, device specs, console logs | Only user-typed text and user-picked images |
| Audience | Engineers, QA, beta testers | Real consumers in production |
Install #
dependencies:
shake_context: ^0.2.0
Demo #
Production Mode
|
Dev Mode
|
Usage #
import 'package:flutter/material.dart';
import 'package:shake_context/shake_context.dart';
void main() {
// `ShakeContext.guard` hooks `print`, `debugPrint`, FlutterError.onError,
// and PlatformDispatcher.onError so the overlay can show everything the
// app emits — including third-party loggers and uncaught async errors.
// Skip it and the overlay only sees `debugPrint` output.
ShakeContext.guard(() {
runApp(
ShakeContext(
mode: InspectMode.resolve(),
onReportSubmitted: (ReportPayload payload) async {
if (payload.mode == InspectMode.production) {
await uploadToSupportDesk(payload.userDescription, payload.images);
} else {
await sendToDevOps(payload.metadata, payload.images);
}
},
child: const MyApp(),
),
);
});
}
That's it — shake the device and the right overlay appears for the active mode. Both modes flow through the same onReportSubmitted callback; dispatch by payload.mode.
Platform setup #
-
iOS — if
allowGalleryUploadis on (the default), addNSPhotoLibraryUsageDescriptionto yourios/Runner/Info.plist. Without it, the gallery picker crashes the first time it's invoked:<key>NSPhotoLibraryUsageDescription</key> <string>Attach screenshots to your bug report.</string> -
macOS — if
allowGalleryUploadis on, add the user-selected file read entitlement to bothmacos/Runner/DebugProfile.entitlementsandmacos/Runner/Release.entitlements. Without it, the sandbox silently blocksNSOpenPaneland the "Add image" button does nothing:<key>com.apple.security.files.user-selected.read-only</key> <true/> -
Android / Windows / Linux / web — no native config required. Shake detection is unavailable on macOS/Windows/Linux/web (no accelerometer); use
ShakeContext.triggerReport(context)to open the overlay programmatically there.
Picking the mode #
InspectMode.resolve() codifies the rule "release builds are for real consumers, every other build is internal." It works whether or not your project uses flavors.
Without flavors #
mode: InspectMode.resolve(),
Equivalent to kReleaseMode ? InspectMode.production : InspectMode.developer. Debug and profile builds get the diagnostic overlay; release builds get the consumer feedback sheet.
With flavors #
If your project ships multiple flavors (dev, prod, staging, …), pass the active flavor identifier from your per-flavor entry point:
mode: InspectMode.resolve(flavor: 'dev'), // from main_dev.dart
mode: InspectMode.resolve(flavor: 'prod'), // from main_prod.dart
The helper returns InspectMode.production only when the build is a release build and the flavor is in productionFlavors (default {'prod', 'production'}). Every other combination — including a release build of the dev flavor (e.g. an internal TestFlight track) — returns InspectMode.developer. QA keeps the diagnostic overlay on signed builds without exposing it to real consumers.
Mode matrix #
| Flavor | Build mode | Audience | resolve(flavor: …) returns |
|---|---|---|---|
dev |
debug | local development | developer |
dev |
profile | perf / staging | developer |
dev |
release | TestFlight / internal | developer |
prod |
debug | dev poking prod API | developer |
prod |
profile | rare perf testing | developer |
prod |
release | App Store consumers | production |
Custom flavor names #
For projects that don't call the consumer flavor prod / production:
mode: InspectMode.resolve(
flavor: appFlavor,
productionFlavors: {'live', 'appstore'},
),
Using with flavors #
The Flutter-idiomatic pattern is one entry point per flavor, both delegating to a shared bootstrap.
// lib/main_dev.dart
import 'app/bootstrap.dart';
void main() => bootstrap('dev');
// lib/main_prod.dart
import 'app/bootstrap.dart';
void main() => bootstrap('prod');
// lib/app/bootstrap.dart
import 'package:flutter/material.dart';
import 'package:shake_context/shake_context.dart';
void bootstrap(String flavor) {
// Hooks print/debugPrint/FlutterError/PlatformDispatcher so the overlay
// can show everything the app emits — flavor-agnostic.
ShakeContext.guard(() {
runApp(
ShakeContext(
mode: InspectMode.resolve(flavor: flavor),
onReportSubmitted: (payload) => _send(flavor, payload),
child: MyApp(flavor: flavor),
),
);
});
}
Future<void> _send(String flavor, ReportPayload payload) async {
if (payload.mode == InspectMode.developer) {
// QA / internal — diagnostic dump goes to engineering
await sendToEngineeringChannel(payload);
} else {
// Real consumer feedback goes to the support desk
await sendToSupportDesk(payload);
}
}
Build & run commands #
# Local development against staging API
flutter run --flavor dev -t lib/main_dev.dart
# Internal TestFlight build — release-signed, but developer overlay
flutter build ipa --flavor dev -t lib/main_dev.dart --release
# App Store production build — release-signed, consumer sheet
flutter build ipa --flavor prod -t lib/main_prod.dart --release
Platform flavor wiring #
shake_context doesn't touch native build config — set up flavors however you normally would. Quick pointers:
- Android —
android/app/build.gradleproductFlavors { dev { … }; prod { … } }. - iOS — Xcode schemes + build configurations (
Debug-dev/Release-dev/Debug-prod/Release-prod), one scheme per flavor.
Follow the Flutter flavors guide for the full setup.
Gotchas #
- TestFlight = release build. Don't gate the mode on
kReleaseModealone — your TestFlight QA will see the consumer sheet instead of the diagnostic overlay.InspectMode.resolve(flavor: …)handles this for you. - Bundle ID alone is not enough. A release build of either flavor has
kReleaseMode == true. The package needs the explicit flavor string passed through from your entry point — it can't infer the flavor from the bundle ID at runtime. - Symbol obfuscation. Release builds are obfuscated and tree-shaken by default. If you want readable stack traces in QA reports, build the dev flavor with
--no-obfuscate, or pass--split-debug-info=<dir>per flavor and de-obfuscate server-side when you receive the report. - Different sinks per flavor. It's usually a feature, not a bug, to send dev-flavor reports to your engineering Slack and prod-flavor reports to your support desk. Branch inside
onReportSubmittedusing either the capturedflavor(closed over frombootstrap) orpayload.mode.
Placement: above vs. inside MaterialApp #
The overlay needs a Navigator. Two options:
- Inside
MaterialApp— dropShakeContextintohome:(or wrap your screen tree insidehome). The widget discovers the inheritedNavigatorautomatically. - Above
MaterialApp— share aGlobalKey<NavigatorState>withMaterialApp.navigatorKeyand pass it toShakeContext(navigatorKey: ...). Useful when you want the engine to surviveMaterialApprebuilds.
Host context (installation ID, user ID, …) #
ShakeContext plumbs an extras map straight into every emitted ReportPayload.extras. Use it for identifiers and stage info the package can't know on its own:
ShakeContext(
extras: {
'installationId': await loadOrGenerateInstallationId(), // dedupe
'userId': currentUser?.id, // optional
'releaseChannel': 'beta', // build stage
'experiments': activeFeatureFlags.toList(), // anything JSON
},
// ...
)
Values must be JSON-encodable for payload.toJson() to round-trip cleanly. App version, build number, package name are already captured automatically under metadata.deviceInfo (appVersion, appBuildNumber, appName, appPackageName) via package_info_plus — you don't need to add them to extras yourself.
Runtime master toggle #
ShakeContext(
// ...
isShakeEnabled: userSettings.shakeReportingOn,
)
Flip isShakeEnabled at any time. The engine starts / stops sampling the accelerometer without rebuilding the rest of the app.
Shake sensitivity #
How hard the user has to shake before the sheet opens is tunable via shakeSensitivity. Three presets cover the common cases:
ShakeContext(
shakeSensitivity: const ShakeSensitivity.medium(), // default
// ...
)
| Preset | Feel | When to use |
|---|---|---|
ShakeSensitivity.low() |
Harder — needs a deliberate, vigorous shake | False positives are costly (the sheet would interrupt a critical flow) |
ShakeSensitivity.medium() |
Balanced — what most "shake to feedback" apps feel like | Default |
ShakeSensitivity.high() |
Easier — fires on lighter motion | Accessibility, or QA builds where the trigger should be easy |
For exact tuning (tablets, kiosks, app-specific accessibility), use the unnamed constructor:
ShakeContext(
shakeSensitivity: const ShakeSensitivity(
threshold: 2.5, // min g-force for one sample to count as a spike (lower = easier)
minSpikes: 3, // spikes needed inside `window` to fire
window: Duration(milliseconds: 500), // rolling window spikes are counted in
cooldown: Duration(seconds: 1), // quiet period after a trigger before the next can fire
),
// ...
)
shakeSensitivity can be changed at runtime — the example app exposes a low/medium/high segmented button on its settings page.
Configuration knobs #
ShakeContext(
productionConfig: const ProductionConfig(
strings: ProductionStrings(
title: 'Send us feedback',
hintText: 'What happened? Walk us through it.',
submitLabel: 'Send',
),
allowGalleryUpload: true,
allowScreenshotAttachment: true,
),
developerConfig: const DeveloperConfig(
captureRoute: true,
captureDeviceInfo: true,
captureConsoleLogs: true,
captureScreenshot: true,
logBufferSize: 200,
),
// ...
)
Theming the report sheet #
Both surfaces inherit your app's Theme by default — the sheet recolors itself from the ambient colorScheme with no extra work. When you need to override specific colors without supplying a whole ThemeData, pass a ReportTheme to DeveloperConfig.theme and/or ProductionConfig.theme:
ShakeContext(
productionConfig: const ProductionConfig(
theme: ReportTheme(
primaryColor: Color(0xFF6750A4), // header chip, section icons, focused field border
submitButtonColor: Color(0xFF6750A4), // Send button background
submitButtonTextColor: Colors.white, // Send button label
// backgroundColor, cardColor, borderColor, textColor, subtitleColor,
// onPrimaryColor, cancelButtonColor are all available too.
),
),
// ...
)
Every ReportTheme field is nullable — anything left null falls back to the matching token on the inherited Theme's colorScheme, so you can recolor just the one or two things you care about.
Capturing network traffic #
In developer mode the overlay can show a Network panel listing every HTTP request/response/error the app made — method, URL, status, duration, and (redacted) headers + bodies. Wire it up at your HTTP client's configuration site; nothing is captured until you do.
Dio — add the interceptor (import the dio.dart entry point so dio is tree-shaken when you don't use it):
import 'package:shake_context/dio.dart';
final dio = Dio()..interceptors.add(ShakeDioInterceptor());
http package — wrap your client (import the http.dart entry point):
import 'package:http/http.dart' as http;
import 'package:shake_context/http.dart';
final client = ShakeHttpClient(http.Client());
await client.get(Uri.parse('https://api.example.com/items'));
Any other client — hand-build a NetworkLog and push it yourself:
ShakeContext.recordNetwork(NetworkLog(
method: 'GET',
url: 'https://api.example.com/items',
statusCode: 200,
durationMs: 142,
));
Captured entries feed the developer overlay's Network panel (filterable by failed-only) and, with persistLogs: true, the "Previous session — network" panel after a crash. They are part of ReportMetadata and serialize through toJson().
Network capture is a developer-mode diagnostic. Don't add the interceptor in production-mode builds unless you intend to ship request/response bodies in your reports.
Redaction & size guardrails
Captured headers and bodies are run through a RedactionConfig before they're stored, so secrets never reach the buffer. The defaults are conservative:
| Knob | Default |
|---|---|
redactedHeaderKeys |
authorization, cookie, set-cookie, proxy-authorization, x-api-key, x-auth-token |
redactedBodyKeys |
password, pass, pwd, token, access_token, refresh_token, api_key, apikey, secret, authorization |
maxBodyChars |
2048 (request/response bodies truncated past this) |
maxLogChars |
8192 (any single log message truncated past this) |
maskText |
«redacted» |
Header matching is case-insensitive and exact; body matching is a heuristic regex over the serialized body (catches common JSON shapes — not a full parser). To widen or tighten the policy, pass a custom RedactionConfig to the interceptor or client:
ShakeDioInterceptor(
redaction: const RedactionConfig(
redactedBodyKeys: {'password', 'ssn', 'creditCard'},
maxBodyChars: 4096,
),
captureRequestBody: true, // set false to omit request bodies entirely
captureResponseBody: true,
captureHeaders: true,
);
ShakeHttpClient takes the same redaction / capture* parameters.
Localization #
Every visible string in the production sheet — header, hint, button labels, privacy line, tooltips, the "Sent with your report" disclosure, the SnackBar shown on submission failure — is overridable via ProductionStrings. The defaults are English; pipe your app's per-locale copy through whatever localization layer you already use.
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:shake_context/shake_context.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) {
// `context` here sees the AppLocalizations inherited from MaterialApp.
final l10n = AppLocalizations.of(context)!;
return ShakeContext(
mode: InspectMode.resolve(),
productionConfig: ProductionConfig(
strings: ProductionStrings(
title: l10n.bugReportTitle,
hintText: l10n.bugReportHint,
submitLabel: l10n.bugReportSubmit,
cancelLabel: l10n.bugReportCancel,
privacyNote: l10n.bugReportPrivacyNote,
headerSubtitle: l10n.bugReportSubtitle,
descriptionPrompt: l10n.bugReportDescriptionPrompt,
attachmentsLabel: l10n.bugReportAttachments,
addImage: l10n.bugReportAddImage,
limitReached: l10n.bugReportLimitReached,
noAttachments: l10n.bugReportNoAttachments,
addAttachmentHint: l10n.bugReportAddAttachmentHint,
sentWithReport: l10n.bugReportSentWith,
timeLabel: l10n.bugReportTime,
deviceLabel: l10n.bugReportDevice,
resolvingDeviceInfo: l10n.bugReportResolvingDevice,
showDetails: l10n.bugReportShowDetails,
hideDetails: l10n.bugReportHideDetails,
dismissTooltip: l10n.bugReportDismiss,
removeImageTooltip: l10n.bugReportRemoveImage,
annotateTooltip: l10n.bugReportAnnotate,
tapToView: l10n.bugReportTapToView,
autoBadge: l10n.bugReportAutoBadge,
submissionFailedMessage: l10n.bugReportSubmissionFailed,
),
),
onReportSubmitted: sendReport,
child: child ?? const SizedBox.shrink(),
);
},
// ...
);
}
}
Override only the fields that matter for your locale — anything left out falls back to the English default. The legacy top-level title / hintText / submitLabel / privacyNote parameters on ProductionConfig still work for source compatibility, but they're soft-deprecated in favor of strings.
DeveloperView copy is currently English-only. The developer overlay is an engineer-facing surface and rarely ships to end users; if you have a use case for localizing it, open an issue.
Privacy #
Developer mode auto-captures a screenshot, current route, device snapshot, and the rolling debugPrint log buffer the moment a shake fires. It is intended for internal builds (debug, profile, beta channels) and should not ship to end users.
Production mode never captures telemetry on its own — ReportPayload.metadata is empty unless you explicitly populate it. Only the user-typed text and user-approved image attachments are returned.
What's in the payload? #
class ReportPayload {
final InspectMode mode; // developer or production
final String userDescription; // user-typed text
final List<Uint8List> images; // screenshot + any user-picked images
final ReportMetadata metadata; // route, deviceInfo, logs, networkLogs, timestamp
final Map<String, Object?> extras; // host-provided context (see above)
}
metadata.deviceInfo includes platform / model / OS and appVersion, appBuildNumber, appName, appPackageName — the version is the field every bug tracker asks for first.
metadata is populated in developer mode and empty in production mode. The package never writes anything to disk¹ and never uploads anywhere — onReportSubmitted hands you the bytes; transport is your call.
¹ Except the opt-in crash-recovery log buffer, which is JSON only — no images.
JSON serialization #
Both ReportPayload and ReportMetadata expose toJson(). Images are excluded by default (a imageCount field is emitted instead) so you can ship them out-of-band as multipart — smaller wire, what most backends expect:
final body = jsonEncode(payload.toJson());
// payload.images is a List<Uint8List> you upload separately
Pass includeImages: true when you need a single self-contained JSON blob (e.g. a webhook that can't accept multipart). Each image is base64-encoded inline:
final body = jsonEncode(payload.toJson(includeImages: true));
Heads-up: base64 inflates by ~33%, and the encoding runs on the main isolate — for multi-MB screenshots, prefer the multipart path.
Sending the report #
shake_context is transport-agnostic by design — it never uploads anything
itself. onReportSubmitted hands you a ReportPayload
and you POST it wherever you like. The recipes below are copy-paste ready.
Routing to different endpoints (dev vs. production) #
Both modes flow through the same callback; branch on payload.mode to send
diagnostic reports to engineering and consumer feedback to your support desk.
The base URL itself can switch per build with a --dart-define so debug builds
hit your staging server and release builds hit production:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shake_context/shake_context.dart';
// Pass at build time:
// flutter run --dart-define=API_BASE=https://staging.example.com
// flutter build --dart-define=API_BASE=https://api.example.com
const _apiBase = String.fromEnvironment(
'API_BASE',
defaultValue: 'https://api.example.com',
);
// Different sinks per mode (see InspectMode.resolve()):
// developer → QA/engineering diagnostics production → support desk
const _devEndpoint = '$_apiBase/diagnostics';
const _prodEndpoint = '$_apiBase/feedback';
Future<void> sendReport(ReportPayload payload) async {
final endpoint = payload.mode == InspectMode.developer
? _devEndpoint
: _prodEndpoint;
final req = http.MultipartRequest('POST', Uri.parse(endpoint))
..fields['payload'] = jsonEncode(payload.toJson())
..files.addAll([
for (var i = 0; i < payload.images.length; i++)
http.MultipartFile.fromBytes(
'image_$i',
payload.images[i],
filename: 'attachment_$i.png',
),
]);
final resp = await req.send();
// Throwing here keeps the sheet open and shows the user a SnackBar — and, if
// you enabled it, hands the payload to the retry queue. See "Handling
// submission failures".
if (resp.statusCode >= 400) {
throw Exception('Upload failed: HTTP ${resp.statusCode}');
}
}
// Wire it in:
// ShakeContext(onReportSubmitted: sendReport, ...)
Backend contract — what your server must accept #
Your endpoint just needs to accept a POST and read the payload. Pick one of
two wire formats:
A. Multipart (recommended for real backends) — a payload form field
holding jsonEncode(payload.toJson()), plus one file part per image
(image_0, image_1, …, PNG bytes). Smaller on the wire; images stream as
files. This is the request the snippet above sends.
B. JSON-only (simplest — one jsonDecode on the server) — Content-Type: application/json, body = jsonEncode(payload.toJson(includeImages: true)).
Images are base64-encoded inline (~33% larger). Good for webhooks or a quick
test server:
await http.post(
Uri.parse(endpoint),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode(payload.toJson(includeImages: true)),
);
Either way the JSON your server receives looks like this (fields are omitted when empty — code defensively):
{
"mode": "developer", // or "production" — route on this
"userDescription": "Login button does nothing on the checkout page",
"imageCount": 1,
"images": ["<base64 PNG>"], // only with toJson(includeImages: true)
"extras": { // only if you passed ShakeContext(extras:)
"userId": "u_123",
"releaseChannel": "beta"
},
"metadata": { // rich in developer mode, near-empty in production
"timestamp": "2026-06-02T20:04:42.570Z",
"currentRoute": "/checkout",
"deviceInfo": {
"platform": "ios",
"model": "iPhone15,2",
"osVersion": "18.5",
"appName": "MyApp",
"appPackageName": "com.example.myapp",
"appVersion": "1.4.0",
"appBuildNumber": "142"
},
"logs": [
{ "message": "tapped checkout", "level": "info", "source": "ui", "timestamp": "2026-06-02T20:04:40.110Z" }
],
"networkLogs": [
{ "method": "POST", "url": "https://api.example.com/checkout", "statusCode": 500, "durationMs": 812, "timestamp": "2026-06-02T20:04:41.900Z" }
]
}
}
Minimum server requirements: accept POST, return 2xx on success (any
>= 400 makes the app keep the sheet open / queue for retry), and a body limit
large enough for base64 screenshots if you use format B (a 3.0x screenshot
can be several MB — see Screenshot size). No auth scheme is
imposed; add your own header in the request if your endpoint needs one.
Want to try it locally first? The repo ships a zero-dependency receiver with a live dashboard under
test_backend/—dart run test_backend/server.dart, then submit a report from the example app. See test_backend/README.md, including the physical-device gotchas (LAN IP, iOS cleartext/local-network permissions).
1. Multipart POST to your own endpoint (recommended) #
import 'package:http/http.dart' as http;
Future<void> sendReport(ReportPayload payload) async {
final req = http.MultipartRequest(
'POST',
Uri.parse('https://api.example.com/bug-reports'),
)
..fields['payload'] = jsonEncode(payload.toJson())
..files.addAll([
for (var i = 0; i < payload.images.length; i++)
http.MultipartFile.fromBytes(
'image_$i',
payload.images[i],
filename: 'attachment_$i.png',
contentType: MediaType('image', 'png'),
),
]);
final resp = await req.send();
if (resp.statusCode >= 400) {
throw Exception('Upload failed: ${resp.statusCode}');
}
}
2. JSON-only webhook (Slack, Discord, generic) #
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> sendToSlack(ReportPayload payload) async {
// Slack webhooks don't accept file uploads — upload images to S3/GCS
// first and embed the URLs, or skip images entirely.
await http.post(
Uri.parse('https://hooks.slack.com/services/...'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'text': '🐛 ${payload.userDescription.isEmpty ? "(no note)" : payload.userDescription}',
'attachments': [
{
'fields': [
{'title': 'Route', 'value': payload.metadata.currentRoute ?? '(unknown)'},
{'title': 'Device', 'value': payload.metadata.deviceInfo.toString()},
{'title': 'Images', 'value': '${payload.images.length}'},
],
},
],
}),
);
}
3. Email via share_plus (no backend required) #
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
Future<void> emailReport(ReportPayload payload) async {
final dir = await getTemporaryDirectory();
final files = <XFile>[];
for (var i = 0; i < payload.images.length; i++) {
final path = '${dir.path}/report_$i.png';
await File(path).writeAsBytes(payload.images[i]);
files.add(XFile(path));
}
await Share.shareXFiles(
files,
subject: 'Bug report',
text: '${payload.userDescription}\n\n'
'${const JsonEncoder.withIndent(" ").convert(payload.metadata.toJson())}',
);
}
4. Sentry user feedback #
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> sendToSentry(ReportPayload payload) async {
final id = await Sentry.captureMessage(
'User bug report',
withScope: (scope) {
for (var i = 0; i < payload.images.length; i++) {
scope.addAttachment(
SentryAttachment.fromUint8List(
payload.images[i],
'attachment_$i.png',
contentType: 'image/png',
),
);
}
},
);
await Sentry.captureUserFeedback(SentryUserFeedback(
eventId: id,
comments: payload.userDescription,
));
}
Handling submission failures #
shake_context calls onReportSubmitted and awaits it. If it throws, the sheet stays open and the user sees the SnackBar — but unless the queue is enabled, the payload is lost the moment they dismiss the sheet.
Built-in retry queue (recommended)
Opt in by passing enableRetryQueue: true to ShakeContext.guard:
void main() {
ShakeContext.guard(
() => runApp(const MyApp()),
enableRetryQueue: true,
// Defaults shown — tune for your tolerance.
retryQueueMaxAge: const Duration(days: 7),
retryQueueMaxEntries: 20,
);
}
When onReportSubmitted throws, the payload (description, attachments, metadata, extras — everything toJson(includeImages: true) serialises) is written to <applicationSupportDirectory>/shake_context/queue/<id>.json before the failure propagates back to the view. Five seconds after the next app launch the queue is drained: each entry is re-handed to your onReportSubmitted. Successful deliveries delete the file; failures stay queued for the launch after that.
Caps prevent runaway growth: retryQueueMaxEntries is a FIFO ceiling (oldest evicted on overflow); retryQueueMaxAge drops anything older than the cap on each replay pass without re-trying it. The default 20 entries × ~6 MB high-DPI screenshots is ~120 MB worst case — turn screenshotPixelRatio down or maxEntries down if your users have small storage budgets.
Three static methods drive a "Pending reports" UI from a settings screen:
final pending = await ShakeContext.queuedReportCount();
final delivered = await ShakeContext.replayQueuedReports(); // "Retry now"
await ShakeContext.clearQueuedReports(); // logout hook
replayQueuedReports() is reentrant — calling it while the auto-replay is already in flight collapses onto the same future, so no payload is ever double-sent.
Privacy footprint
Queued reports sit on disk under applicationSupportDirectory until they replay successfully or eviction drops them. They include the user's typed text and any attached images. The directory follows the app's install lifecycle (gone on uninstall). For an explicit "discard everything" hook (e.g. after logout), call clearQueuedReports().
Manual queueing (if you'd rather own it)
Future<void> sendReport(ReportPayload payload) async {
try {
await _upload(payload);
} catch (_) {
final queue = await _openQueueFile();
await queue.writeAsString(jsonEncode(payload.toJson(includeImages: true)) + '\n', mode: FileMode.append);
}
}
Screenshot size #
The auto-captured screenshot is rasterised at screenshotPixelRatio: 3.0 by default — visually crisp on every modern display, but on a 1080p phone a single PNG can land in the 5–8 MB range. For a 4-attachment report on a high-DPI tablet, that adds up fast.
Two knobs to manage size:
ShakeContext(
developerConfig: const DeveloperConfig(screenshotPixelRatio: 2.0),
productionConfig: const ProductionConfig(
screenshotPixelRatio: 1.5, // production users have less bandwidth
maxImages: 2, // and tighter expectations on report size
),
// ...
)
For multi-MB screenshots you intend to upload, also consider recompressing before transport with package:image:
import 'package:image/image.dart' as img;
Uint8List compress(Uint8List png, {int quality = 80}) {
final decoded = img.decodePng(png)!;
return Uint8List.fromList(img.encodeJpg(decoded, quality: quality));
}
Crash recovery #
Opt in via ShakeContext.guard(persistLogs: true). The previous session's log + network buffers are persisted to <applicationSupportDirectory>/shake_context/session.json and surfaced in the next session's developer overlay under "Previous session — logs" / "Previous session — network". Useful for catching crashes that happened before the user could shake.
void main() {
ShakeContext.guard(
() => runApp(const MyApp()),
persistLogs: true,
);
}
Persistence is best-effort: a failed write never crashes the host app. Web is not supported (no application-support directory).
Platform support #
Tier 1 — first-party, verified #
| Capability | Android | iOS | macOS | Web |
|---|---|---|---|---|
| Shake detection | ✅ | ✅ | — | — |
triggerReport |
✅ | ✅ | ✅ | ✅ |
| Screenshot | ✅ | ✅ | ✅ | ✅ |
| Device info | ✅ | ✅ | ✅ | ✅ |
| App version | ✅ | ✅ | ✅ | ✅ |
| Network capture | ✅ | ✅ | ✅ | ✅ |
| Gallery picker | ✅ | ✅ | ✅† | ✅ |
| Crash recovery | ✅ | ✅ | ✅ | — |
| Retry queue | ✅ | ✅ | ✅ | — |
Shake detection depends on sensors_plus, which only ships an accelerometer on Android and iOS. On every other platform (macOS, Windows, Linux, web), wire a button to ShakeContext.triggerReport(context) instead.
Crash recovery (persistLogs: true) and the retry queue (enableRetryQueue: true) both use getApplicationSupportDirectory() from path_provider, which has no web implementation — both opt-ins silently no-op on web rather than throwing.
Web verification was done on Chrome and Safari (covering both the Blink and WebKit engines, plus Chromium derivatives like Brave / Edge); Firefox (Gecko) hasn't been driven through the example end-to-end. † requires a macOS sandbox entitlement — see Platform setup.
Tier 2 — community plugins, untested #
Windows and Linux should work — every dependency (sensors_plus, device_info_plus, image_picker, path_provider, package_info_plus) ships a platform implementation — but the maintainer hasn't verified them end-to-end. Please file an issue with platform context if you hit anything.
Web caveats #
- Gallery picker requires a real user gesture.
image_pickeron web is backed by<input type="file">; some browsers only honor.click()calls when the dispatching JS frame is part of the same task as a user gesture. The package wires the picker through a MaterialTextButton, which keeps the call on the gesture's microtask, so the default "Add image" path works in Chrome. If you trigger the report from a non-tap path (e.g. a programmatic timer), the file dialog may be blocked. - Screenshot pixel ratio.
RepaintBoundary.toImageworks in Chrome (Blink) and Safari (WebKit) at the defaultscreenshotPixelRatio: 3.0. Firefox (Gecko) hasn't been driven through the example — if you need cross-browser-predictable output, overridescreenshotPixelRatioexplicitly.
Example #
A multi-page demo lives in example/ — home page, settings page with a runtime toggle, and a second route to exercise route-name capture. Run it with flutter run from inside example/.
Contributing #
The package is built in small, focused plans under plans/ (Plans 01–06 shipped in 0.1.0, Plans 07–09 in 0.2.0). Each plan ends with a green flutter analyze and flutter test, so contributions can land plan-by-plan without breaking the tree.
License #
MIT.