flutter_app_watermark 1.1.0
flutter_app_watermark: ^1.1.0 copied to clipboard
Watermark your entire Flutter app in one line, or scope it to a single widget. Text or image, with configurable angle, opacity and spacing. Pure Dart, no dependencies.
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 |
| 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.
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.