flutter_app_watermark

Watermark your entire Flutter app in one line.
Text or image. Dialogs, bottom sheets and pushed routes included — nothing covers it.

pub package zero dependencies all platforms MIT license

▶ Try it live  ·  简体中文

flutter_app_watermark demo


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 addPostFrameCallback and the initial-route path throws setState() 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 that Set is visible in review; a missing Watermark(...) 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.

didPop fires 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 — didPush turns 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 NetworkImage that gap is as long as the request. Pass onImageError to get load failures; without it they go to FlutterError, same as an Image widget with no errorBuilder.

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 RepaintBoundary and is marked isComplex + !willChange, so app repaints don't drag it along
  • maxTiles is 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

MIT

Libraries

flutter_app_watermark
Flutter 水印组件:全局水印 + 局部水印,文字或图片,角度 / 透明度均可配置。