How to Build a Wallet Signature Request Sheet in Flutter (Full Code + Preview)
Signature requests are where wallet users lose funds without ever sending a transaction: one tap on an off-chain permit can let a contract move their tokens later. This tutorial builds Aurum's signing sheet in Flutter, a bottom sheet over a scrim that rejects on tap, showing the requesting site, the raw EIP-712 permit line by line, a gas row explaining why signing is free, an amber spend warning, and Reject and Sign buttons. You end with one stateless widget driven by two callbacks.

What you'll build
- ✓A signing prompt whose every exit except the gold button resolves to `onReject`, so nothing gets signed by accident
- ✓A raw message panel that prints the whole Permit body (spender, token, value, nonce, deadline) without truncating the spender address
- ✓An off-chain gas row that tells the signer, in green, that producing this signature costs nothing
- ✓An amber warning card, coloured apart from the gold brand, that names the asset the permit exposes
- ✓Equal-width Reject and Sign buttons whose ranking comes from fill and colour alone
Step-by-step build
Create the file
Add a new file at lib/web3_sign_request/web3_sign_request_screen.dart in your Flutter project.
Register the bundled fonts
No external packages — this is pure Flutter. It does bundle its design font (Inter), so drop the font file into fonts/ and declare it in pubspec.yaml:
flutter:
fonts:
- family: Inter
fonts:
- asset: fonts/Inter-Regular.ttfBuild it, piece by piece
Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.
Two callbacks and a permit held as a constant
import 'package:flutter/material.dart';
/// Web3 — Sign Request. A signature-request sheet with a raw message panel, a
/// gas estimate, a risk warning and sign / reject actions. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, no glyphs / no network
/// image, forced dark theme so it renders standalone as a route.
class Web3SignRequestScreen extends StatelessWidget {
const Web3SignRequestScreen({super.key, this.onSign, this.onReject});
final VoidCallback? onSign;
final VoidCallback? onReject;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0E11);
static const Color _surface = Color(0xFF161A1E);
static const Color _surfaceAlt = Color(0xFF1E2329);
static const Color _brand = Color(0xFFF0B90B);
static const Color _warn = Color(0xFFF0A020);
static const Color _up = Color(0xFF2EBD85);
static const Color _text = Color(0xFFEAECEF);
static const Color _muted = Color(0xFF848E9C);
static const Color _hairline = Color(0xFF2B3139);
static const String _message =
'Permit: UniSwap V4\n'
'spender: 0x000000000022D473030F116dDEE9F6B43aC78BA3\n'
'token: USDC\n'
'value: 1000000000\n'
'nonce: 14\n'
'deadline: 1782096000';
`Web3SignRequestScreen` is a `StatelessWidget` with exactly two nullable hooks, `onSign` and `onReject`. There is no `onClose` or `onDismiss` because on a signing prompt every way out that isn't Sign is a rejection, and the code enforces that later. The palette adds `_warn` (`#F0A020`) as a separate token from the gold `_brand` (`#F0B90B`): close in hue, but the warning must never be mistaken for the primary action. `_message` is a multi-line `static const String` modelled on a real Permit2 approval: the spender `0x000000000022D473…BA3` is the canonical Permit2 contract, `value: 1000000000` is in USDC base units (6 decimals, so 1,000 USDC), and `deadline: 1782096000` is a Unix timestamp for 22 June 2026.
Every way out leads to Reject
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: Colors.black.withValues(alpha: 0.55),
body: Stack(
children: <Widget>[
Positioned.fill(
child: GestureDetector(
onTap: onReject,
behavior: HitTestBehavior.opaque,
),
),A signing prompt should fail closed, and this block is where that rule is set. The route's `Scaffold` is itself the dimmed backdrop (`Colors.black.withValues(alpha: 0.55)`), and the first `Stack` layer is a full-bleed, childless `GestureDetector` whose only job is to call `onReject`. `HitTestBehavior.opaque` is set explicitly because a detector with no child would otherwise hit-test as translucent. The sheet comes later in the same `Stack`, so taps on it are resolved there and never leak through to the backdrop. One exit is not covered: Android's system back gesture pops the route without calling either callback. Wrap the Scaffold in `PopScope` and call `onReject` from its pop callback so the dApp always gets an answer.
A content-sized sheet that names the requester
Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: const BoxDecoration(
color: _bg,
borderRadius:
BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: _hairline,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 18),
const Center(
child: Text(
'Signature request',
style: TextStyle(
fontFamily: _font,
fontSize: 18,
fontWeight: FontWeight.w800,
color: _text,
),
),
),
const SizedBox(height: 4),
const Center(
child: Text(
'app.uniswap.org wants you to sign',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
color: _muted,
),
),
),
const SizedBox(height: 18),
const Text(
'Message',
style: TextStyle(
fontFamily: _font,
fontSize: 13,
fontWeight: FontWeight.w700,
color: _muted,
),
),The panel is a `Container` aligned to `Alignment.bottomCenter` with 24px radii on its top corners only, and its `Column` is `MainAxisSize.min`, so the sheet's height comes from the payload rather than the screen. That is convenient for this six-line permit, but there is no scroll view anywhere: a long typed-data request would push the sheet past the top and overflow. For real payloads, wrap the message panel in a `ConstrainedBox` with a `maxHeight` and a `SingleChildScrollView`. The `_hairline` bar at the top is decoration, not a drag handle. The heading pair is the important part. 'Signature request' at 18px `w800`, then 'app.uniswap.org wants you to sign' in muted 12.5px, puts the requesting domain ahead of the payload, which is the first thing a careful signer checks.
The raw message panel
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _surfaceAlt,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
_message,
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
height: 1.5,
color: _text,
),
),
),Under a muted 'Message' label, a `width: double.infinity` Container in `_surfaceAlt` (`#1E2329`) with 14px padding and a 12px radius renders `_message` as a single `Text`. The `\n` breaks in the constant produce one field per line, and `height: 1.5` at 12.5px gives the 42-character spender room to wrap onto a second line without crowding the next field. Nothing is truncated or shortened to `0x0000…8BA3`, which is the honest choice when the user is approving a contract. Note what the panel does not do: there is no raw/decoded toggle, the text is set in proportional Inter rather than a monospace face, and it is not a `SelectableText`, so the user cannot copy the spender to check it elsewhere.
Why the gas row reads Free
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _hairline),
),
child: Row(
children: const <Widget>[
Icon(Icons.local_gas_station_rounded,
size: 17, color: _muted),
SizedBox(width: 10),
Text(
'Estimated gas',
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
color: _muted,
),
),
Spacer(),
Text(
'Free (off-chain)',
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: _up,
),
),
],
),
),The fee line is a `_surface` Container with a `_hairline` border holding one `Row`: a 17px `Icons.local_gas_station_rounded`, 'Estimated gas' in muted 13.5px, a `Spacer`, and 'Free (off-chain)' in bold `_up` green (`#2EBD85`). The value is correct for this request: signing an EIP-712 permit only produces a signature on the device, and gas is paid later by whoever submits it on-chain, usually the dApp. Green therefore marks this as a non-cost, and the parenthetical explains why. It is a hard-coded string, though, so if you reuse this sheet for `eth_sendTransaction` you need to replace it with a real estimate in ETH and fiat.
The spend-permission warning
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _warn.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: _warn.withValues(alpha: 0.4)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(Icons.warning_amber_rounded,
size: 18, color: _warn),
SizedBox(width: 10),
Expanded(
child: Text(
'This permit lets the spender move your USDC. '
'Only sign for sites you trust.',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
height: 1.4,
fontWeight: FontWeight.w500,
color: _text,
),
),
),
],
),
),The risk card uses the two-strength tint idiom with the dedicated `_warn` colour: `withValues(alpha: 0.10)` for the fill and `withValues(alpha: 0.4)` for the border, so it reads as a caution box and not a gold brand surface. The `Row` sets `crossAxisAlignment: CrossAxisAlignment.start` and puts the copy in an `Expanded`, keeping the 18px `Icons.warning_amber_rounded` beside the first line when the sentence wraps. The copy is in `_text` at `w500` rather than muted, because this is the line most worth reading. It names the asset ('move your USDC') rather than giving a generic 'be careful', which is exactly the risk the gas row's 'Free' might otherwise make the user underestimate.
Reject and Sign: same size, different weight
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: SizedBox(
height: 50,
child: OutlinedButton(
onPressed: onReject,
style: OutlinedButton.styleFrom(
side: const BorderSide(color: _hairline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
),
child: const Text(
'Reject',
style: TextStyle(
fontFamily: _font,
fontSize: 15.5,
fontWeight: FontWeight.w700,
color: _text,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 50,
child: FilledButton(
onPressed: onSign,
style: FilledButton.styleFrom(
backgroundColor: _brand,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
),
child: const Text(
'Sign',
style: TextStyle(
fontFamily: _font,
fontSize: 15.5,
fontWeight: FontWeight.w700,
color: _bg,
),
),
),
),
),
],
),
],
),
),
),
),
),
],
),
),
);
}
}Both buttons sit in `Expanded` halves of a `Row` at a fixed 50px height, with 12px between them. Keeping Sign the same size as Reject is a choice against dark patterns: a wallet that inflates the approve button is steering people into signing payloads they skimmed. The difference is carried entirely by surface. Reject, on the left, is an `OutlinedButton` with a `_hairline` side and a `_text` label. Sign, on the right where most wallets place confirmation, is a `FilledButton` in `_brand` gold with a near-black `_bg` label for contrast. Radii (13px) and type (15.5px `w700`) are identical, so neither looks like a secondary link. Sign has only this one entry point, and because `onPressed` takes the nullable `onSign` directly, passing null greys the button out. That is useful while a payload is still being decoded.
Full code
The complete, ready-to-paste source. Free to use in your projects — one click copies it all.
import 'package:flutter/material.dart';
/// Web3 — Sign Request. A signature-request sheet with a raw message panel, a
/// gas estimate, a risk warning and sign / reject actions. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, no glyphs / no network
/// image, forced dark theme so it renders standalone as a route.
class Web3SignRequestScreen extends StatelessWidget {
const Web3SignRequestScreen({super.key, this.onSign, this.onReject});
final VoidCallback? onSign;
final VoidCallback? onReject;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0E11);
static const Color _surface = Color(0xFF161A1E);
static const Color _surfaceAlt = Color(0xFF1E2329);
static const Color _brand = Color(0xFFF0B90B);
static const Color _warn = Color(0xFFF0A020);
static const Color _up = Color(0xFF2EBD85);
static const Color _text = Color(0xFFEAECEF);
static const Color _muted = Color(0xFF848E9C);
static const Color _hairline = Color(0xFF2B3139);
static const String _message =
'Permit: UniSwap V4\n'
'spender: 0x000000000022D473030F116dDEE9F6B43aC78BA3\n'
'token: USDC\n'
'value: 1000000000\n'
'nonce: 14\n'
'deadline: 1782096000';
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: Colors.black.withValues(alpha: 0.55),
body: Stack(
children: <Widget>[
Positioned.fill(
child: GestureDetector(
onTap: onReject,
behavior: HitTestBehavior.opaque,
),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
decoration: const BoxDecoration(
color: _bg,
borderRadius:
BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: _hairline,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 18),
const Center(
child: Text(
'Signature request',
style: TextStyle(
fontFamily: _font,
fontSize: 18,
fontWeight: FontWeight.w800,
color: _text,
),
),
),
const SizedBox(height: 4),
const Center(
child: Text(
'app.uniswap.org wants you to sign',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
color: _muted,
),
),
),
const SizedBox(height: 18),
const Text(
'Message',
style: TextStyle(
fontFamily: _font,
fontSize: 13,
fontWeight: FontWeight.w700,
color: _muted,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _surfaceAlt,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
_message,
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
height: 1.5,
color: _text,
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _hairline),
),
child: Row(
children: const <Widget>[
Icon(Icons.local_gas_station_rounded,
size: 17, color: _muted),
SizedBox(width: 10),
Text(
'Estimated gas',
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
color: _muted,
),
),
Spacer(),
Text(
'Free (off-chain)',
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: _up,
),
),
],
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _warn.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: _warn.withValues(alpha: 0.4)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(Icons.warning_amber_rounded,
size: 18, color: _warn),
SizedBox(width: 10),
Expanded(
child: Text(
'This permit lets the spender move your USDC. '
'Only sign for sites you trust.',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
height: 1.4,
fontWeight: FontWeight.w500,
color: _text,
),
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: SizedBox(
height: 50,
child: OutlinedButton(
onPressed: onReject,
style: OutlinedButton.styleFrom(
side: const BorderSide(color: _hairline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
),
child: const Text(
'Reject',
style: TextStyle(
fontFamily: _font,
fontSize: 15.5,
fontWeight: FontWeight.w700,
color: _text,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: SizedBox(
height: 50,
child: FilledButton(
onPressed: onSign,
style: FilledButton.styleFrom(
backgroundColor: _brand,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
),
child: const Text(
'Sign',
style: TextStyle(
fontFamily: _font,
fontSize: 15.5,
fontWeight: FontWeight.w700,
color: _bg,
),
),
),
),
),
],
),
],
),
),
),
),
),
],
),
),
);
}
}
Plus bundled 1 binary asset (fonts/images). The CLI and MCP install those for you automatically.
Two faster ways to add it
Copy-paste works, but you can skip it entirely.
1. FlutterKit CLI
One command drops this screen — and its fonts — straight into your project.
$ flutterkit add web3-sign-request2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install web3-sign-request — it fetches and writes the files for you.
FAQ
Can I ship this signature request sheet in a commercial wallet?
Yes. It is free for commercial use. Copy the file from this page, install it with the CLI, or fetch it through MCP and put it in a production app. There is no licence key to enter and no attribution to display.
Should I add a decoded view next to the raw message?
Yes, for production. As written the panel only shows the raw `key: value` text, with no toggle. A common pattern keeps a `bool _showRaw` in a `StatefulWidget` and adds a small 'Raw / Decoded' switch above the panel. The decoded side shows the outcome in plain words ('Allow Permit2 to spend up to 1,000 USDC until 22 Jun 2026'), and the raw side keeps the exact fields for people who want to verify them. Switch the raw text to a monospace font and a `SelectableText` so addresses can be copied and compared.
How do I feed a real EIP-712 request into the message panel?
Turn `_message` into a constructor parameter and build it from the request's typed data: the domain name, then each field of the message. Format values for humans as you build it. Divide `value` by 10^decimals (1000000000 becomes 1,000 USDC), convert `deadline` from Unix seconds to a date, and flag max-uint256 amounts as unlimited. Put the actual signing call inside `onSign`, and pass the origin domain in too instead of the hard-coded app.uniswap.org.
Does this screen need any packages or font setup?
No packages. The only import is `package:flutter/material.dart`, and both icons (`local_gas_station_rounded`, `warning_amber_rounded`) come from the SDK's Material set. Text uses `fontFamily: 'Inter'`, and the font file ships with the screen at `lib/web3_sign_request/fonts/Inter.ttf`, so nothing is downloaded at runtime and you don't need `google_fonts`.
What Flutter version is required?
Flutter 3.22 or newer. The scrim and the warning card's fill and border call `Color.withValues(alpha: ...)`, and the constructor uses the `super.key` parameter. For an older SDK, change each `withValues(alpha: x)` to `withOpacity(x)` and write the constructor as `{Key? key, this.onSign, this.onReject}) : super(key: key)`.