flutter_app_watermark 1.0.1
flutter_app_watermark: ^1.0.1 copied to clipboard
Watermark your entire Flutter app in one line, or scope it to a single widget. Text, angle, opacity and spacing are configurable. Pure Dart, no dependencies.
flutter_app_watermark
Watermark your entire Flutter app in one line.
Dialogs, bottom sheets and pushed routes included — nothing covers it.
▶ Try it live · 简体中文
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 |
| 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.0.1
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.
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: drag angle, opacity, font 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 #
MIT