flutter_app_watermark
Watermark your entire Flutter app in one line.
Text or image. Dialogs, bottom sheets and pushed routes included — nothing covers it.
Why
Screenshots of internal dashboards, contracts and customer data leak all the time. A watermark carrying who was looking and when turns an anonymous screenshot into a traceable one — and makes people think twice before taking it.
Doing that properly is trickier than it looks: the watermark has to survive route changes, sit above modals, never swallow a tap, never get read aloud by a screen reader, and not cost you frames. This package does all of it in one widget.
| One line, whole app | Wrap WatermarkScope outside MaterialApp — every route, dialog, bottom sheet and page transition is covered |
| Or just one widget | Watermark scopes it to a single card without touching its layout |
| Or just certain pages | Keep one global scope and toggle it from a NavigatorObserver; dialogs stay covered |
| Text or an image | Tile a string, or tile any ImageProvider — an asset logo, a network badge |
| Live updates | Change the text after sign-in; only the watermark layer repaints, your widget tree doesn't rebuild |
| Crop-resistant | Staggered rows plus a per-user offset, so a cropped screenshot still points back to one person |
| Invisible to input | IgnorePointer plus a hitTest override — buttons underneath stay tappable |
| Cheap | Covers only the bounding box the rotation needs, lays out the text once, caches the layer |
| Zero dependencies | Pure Dart. No native code, no plugins, works on mobile, web and desktop |
A watermark deters screenshots and makes leaks traceable — it does not block screen capture. If you need
FLAG_SECURE-style blocking, pair this with a dedicated plugin.
Install
dependencies:
flutter_app_watermark: ^1.1.0
Requires Flutter >=3.27.0.
Quick start
import 'package:flutter_app_watermark/flutter_app_watermark.dart';
final watermark = WatermarkController();
void main() {
runApp(
WatermarkScope( // ← outside MaterialApp
controller: watermark,
child: const MyApp(),
),
);
}
// after sign-in
watermark.update(lines: [
'${user.name} ${user.maskedPhone}',
DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()),
]);
That's the whole integration. Everything below is optional.
Scope it to one widget
Watermark(
lines: const ['Jane Doe · #10086', 'Internal use only'],
style: const WatermarkStyle(opacity: 0.22, rotation: -25),
child: const ContractCard(),
)
Watermark sizes itself to its child and never stretches it. Watermark.text('...') is the single-line shorthand, and enabled: false hides it without changing the tree.
Watermark only certain pages
Only the contract page and the customer detail page need a watermark, the rest don't — probably the most common requirement of all.
Don't wrap each page in Watermark. It only covers that page's own subtree,
so it won't cover a showDialog / showModalBottomSheet / SnackBar opened
from that page — those are separate entries in the Navigator's overlay, painted
above the page. And forgetting one page fails silently: nothing looks wrong.
Keep a single global WatermarkScope and toggle it from a NavigatorObserver
based on what's currently on the route stack:
const Set<String> kSecureRoutes = {'/contract', '/customer/detail'};
class WatermarkRouteObserver extends NavigatorObserver {
WatermarkRouteObserver(this.controller);
final WatermarkController controller;
final Set<Route<dynamic>> _secure = <Route<dynamic>>{};
bool _isSecure(Route<dynamic>? route) =>
kSecureRoutes.contains(route?.settings.name);
void _sync() {
// didPush can fire inside the Navigator's own build (when the initial route
// is already a secure one), and touching the controller there would trip
// "markNeedsBuild called during build".
WidgetsBinding.instance.addPostFrameCallback(
(_) => controller.update(enabled: _secure.isNotEmpty),
);
}
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (_isSecure(route) && _secure.add(route)) _sync();
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (_secure.remove(route)) _sync();
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (_secure.remove(route)) _sync();
}
}
Wire it up with the controller starting off:
final watermark = WatermarkController(lines: [...], enabled: false);
runApp(WatermarkScope(
controller: watermark,
child: MaterialApp(
navigatorObservers: [WatermarkRouteObserver(watermark)],
// ...
),
));
Three deliberate choices:
- It tracks whether any secure route is still on the stack, not "is the current route secure". Pushing a plain page or a dialog route on top of a secure one then can't switch the watermark off by mistake.
- The toggle is deferred by one frame. Drop that
addPostFrameCallbackand the initial-route path throwssetState() or markNeedsBuild() called during build— which is an assert, so it stays silent in release. It's the easiest line to delete as "redundant". - The list of secure pages lives in one
Set. Forget to add a new page and at least thatSetis visible in review; a missingWatermark(...)among ten page files is completely silent.
While enabled is false the layer is just a SizedBox.shrink(), so leaving the
scope mounted costs nothing.
didPopfires when the exit animation starts, so the watermark is already off during the ~300 ms the secure page fades out. If that matters, delay the switch-off by one transition duration. The push direction is safe as is —didPushturns it on immediately.
The full version (with didReplace) and its tests live in
test/watermark_route_toggle_test.dart,
which also pins down the during-build edge case above.
Tile an image instead
WatermarkScope.image(
const AssetImage('assets/logo.png'),
width: 72, // height follows the original aspect ratio
style: const WatermarkStyle(opacity: 0.08, rotation: 0),
child: const MyApp(),
)
Watermark.image(...) does the same for a single widget, and
controller.updateImage(...) swaps it at runtime. Any ImageProvider works —
AssetImage, NetworkImage, MemoryImage, FileImage. The package never
touches the asset pipeline itself.
Text and image are separate content channels: one scope shows one or the other, and the constructors don't mix — there is no call where you could pass both and wonder what happens. Want both? Nest two scopes, and give each its own spacing — a sparse logo under a dense name-and-timestamp grid:
WatermarkScope(
lines: ['Jane Doe · #10086', '2026-08-20 14:30'],
child: WatermarkScope.image(
const AssetImage('assets/logo.png'),
style: const WatermarkStyle(rowSpacing: 220, columnSpacing: 260),
child: const MyApp(),
),
)
Two things to know:
- The text fields of
WatermarkStyle(color,fontSize,fontWeight,fontFamily,lineHeight,textAlign) don't apply to an image. Everything geometric —opacity,rotation, spacing,staggered,offset,maxTiles— works the same for both. - Decoding is asynchronous, so the very first frame has no watermark on it. For
a
NetworkImagethat gap is as long as the request. PassonImageErrorto get load failures; without it they go toFlutterError, same as anImagewidget with noerrorBuilder.
An image carries no per-user information. A logo proves whose system this is, not who took the screenshot. Keep a text layer if you need to trace a leak back to a person.
Read the controller from anywhere
WatermarkScope.of(context).update(lines: ['Jane Doe · +1 555 0100']);
WatermarkScope.of(context).update(enabled: false); // e.g. internal accounts
Styling
const WatermarkStyle(
opacity: 0.15,
rotation: -30,
fontSize: 14,
columnSpacing: 80,
rowSpacing: 60,
staggered: true,
)
All WatermarkStyle fields
| Field | Type | Default | Description |
|---|---|---|---|
opacity |
double |
0.15 |
Opacity 0–1, multiplied with the alpha already in color |
rotation |
double |
-30 |
Degrees, negative is counter-clockwise, around the canvas center |
color |
Color |
0xFF9E9E9E |
Text color |
fontSize |
double |
14 |
Font size |
fontWeight |
FontWeight |
normal |
Font weight |
fontFamily |
String? |
null |
Font family |
lineHeight |
double |
1.35 |
Line-height multiplier for multi-line text |
textAlign |
TextAlign |
center |
Alignment of the lines inside one tile |
rowSpacing |
double |
60 |
Vertical gap between rows |
columnSpacing |
double |
80 |
Horizontal gap between columns |
staggered |
bool |
true |
Shift odd rows by half a tile (crop resistance) |
offset |
Offset |
zero |
Offsets the whole pattern — useful to shift it per user |
maxTiles |
int |
800 |
Tile budget per paint; exceeding it thins the grid and warns in debug |
WatermarkStyle is an immutable value object — use copyWith for partial changes.
Making leaks traceable
Put three things in the watermark: who, a unique key, and when.
watermark.update(lines: [
'${user.name} ${user.maskedPhone}',
'ID ${user.employeeId}',
DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()),
]);
Then shift the pattern per user. Even if most of a screenshot is cropped away, where the remaining tiles sit still identifies one account:
final seed = user.id.hashCode;
style.copyWith(offset: Offset((seed % 37).toDouble(), (seed % 23).toDouble()));
What it covers
WatermarkScope lives at the very top of the widget tree.
Covered — every route including during transitions · showDialog / showModalBottomSheet / SnackBar · the status bar area and safe areas · Flutter layers above platform views such as WebView and maps.
Not covered — system permission dialogs and the IME panel · the system's post-screenshot preview screen · anything after the user leaves the app. All of it lives outside Flutter, so no pure-Dart package can reach it.
Performance
- Fills only the bounding box the rotation actually needs (
w·|cosθ| + h·|sinθ|×w·|sinθ| + h·|cosθ|) instead of naively covering twice the diagonal — the naive approach draws roughly 4–5× more tiles - Text is laid out once per painter and reused; scrolling and rotation don't re-run layout
- The layer sits behind a
RepaintBoundaryand is markedisComplex + !willChange, so app repaints don't drag it along maxTilesis a backstop, so one mistyped font size can't tank the frame rate
On a 390×844 phone with default settings that's about 110 tiles in a single cached layer.
Example app
cd example && flutter run
A small sales app that adapts from phone to desktop: switch between text and image content, drag angle, opacity, size and spacing live, and check that dialogs, bottom sheets and pushed routes are all covered. Ships with 13 locales, RTL included.
Tests
flutter test
Support
If this package saved you some time, a 👍 on pub.dev or a ⭐ on GitHub helps other people find it.
Hit a bug, or have a case it doesn't cover yet? Open an issue — happy to talk it through. PRs welcome too.
License
Libraries
- flutter_app_watermark
- Flutter 水印组件:全局水印 + 局部水印,文字或图片,角度 / 透明度均可配置。