SignForDeaf Mobile Sign Language
✨ What's new in 2.0.0
- The app is no longer blocked. No scrim, no half-screen sheet: loading, playback and errors all render in a floating player that occupies a corner, so the page underneath stays readable, scrollable and tappable while a translation runs.
- Smart tap passthrough. Tap mode no longer swallows every tap — plain text starts a translation, while buttons, links, checkboxes and text fields go straight to your app. Control labels are translated too, so a Deaf user can read a contract and press the button that agrees to it.
- Sentence-level translation with
‹ n/m ›navigation through the tapped paragraph, and the next sentence prefetched while the current one plays. - Playback controls: play/pause, replay, loop and speed, remembered between sessions.
- The floating button stays where you leave it, and the player opens on the side it is docked to.
- Minimum Flutter is now 3.27.
The public API only grew — nothing that compiled against 1.x has been removed. See the CHANGELOG for the full list.
🚀 Setup (recommended): SignForDeafInit
Wrap your app once at the very top — the same ergonomics as ScreenUtilInit.
The SDK is then active on every screen (floating button, tap-to-translate,
selection menu, SignForDeaf.of(context)), for any router.
void main() => runApp(
SignForDeafInit(
config: const SignForDeafConfig(
apiKey: 'YOUR_API_KEY', // rk parameter
apiUrl: 'YOUR_API_URL', // API base URL, e.g. https://kor01rp02.signfordeaf.com
// originUrl: 'https://yourapp.example', // optional — Origin header + `url`
// // param; defaults to apiUrl
// language: SignLanguage.turkish, // tr | en | ar (de/fr/es coming soon)
// fdid: '16', tid: '23',
// theme: SignForDeafTheme(primaryColor: Color(0xFF6750A4)),
// floatingButton: FloatingButtonConfig(hintMaxShows: 2),
// accessibility: SignForDeafAccessibility(announceOnOpen: true),
),
autoEnable: false, // turn on from a settings switch, or true by profile
builder: (context, child) => MaterialApp(
home: const HomePage(),
),
),
);
Config fields (SignForDeafConfig):
| Field | Required | Default | Description |
|---|---|---|---|
apiKey |
✅ | — | Your SignForDeaf API key (rk). |
apiUrl |
✅ | — | API base URL. |
originUrl |
— | apiUrl |
Origin identifying your app/site — sent as the Origin header and the url query param. Override only if your integration needs a distinct origin. |
language |
— | turkish |
tr / en / ar (de/fr/es not yet supported). |
fdid / tid |
— | 16 / 23 |
Dictionary / translator identifiers — together they name the signer, and the idle avatar follows them. See Idle avatar while loading. |
theme |
— | brand purple | primaryColor, textColor, onPrimaryColor, surfaceColor, cornerRadius. |
floatingButton |
— | enabled | Floating button appearance/behavior. |
card |
— | enabled | Floating translation player: position, avatar size, which controls are shown, speeds. |
granularity |
— | sentence |
Translate the tapped sentence or the whole paragraph. |
maxSegmentChars |
— | 900 |
Longest text sent in one request; longer runs split at clause boundaries. |
longPressToTranslate |
— | false |
Long press translates text the host made tappable, while the player is open. |
smartPassthrough |
— | true |
Hand taps on buttons/links/fields to your app instead of capturing them. |
accessibility |
— | — | Screen-reader announcements & labels. |
autoEnable |
— | false |
Enable the SDK on start. |
It sits above MaterialApp, so it is router-agnostic — it never touches the
router's builder, observers or routes. Works identically with MaterialApp,
MaterialApp.router, go_router, auto_route and nested navigators:
SignForDeafInit(
config: const SignForDeafConfig(apiKey: '...', apiUrl: '...'),
builder: (context, child) => MaterialApp.router(
routerConfig: appRouter, // go_router / auto_route
),
);
It can be freely nested with ScreenUtilInit (either order):
SignForDeafInit(
config: const SignForDeafConfig(apiKey: '...', apiUrl: '...'),
builder: (context, child) => ScreenUtilInit(
designSize: const Size(402, 874),
builder: (context, child) => MaterialApp.router(routerConfig: appRouter),
),
);
The classic pattern below (placing
SignForDeafinsideMaterialApp.builder) also works and stays supported.
🧑🏻💻 Usage
📄main.dart
Wrap your MaterialApp with the SignForDeaf widget and enter the required information
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return SignForDeaf(
requestKey: 'YOUR_API_KEY',
requestUrl: 'YOUR_API_URL',
child: MaterialApp(
title: 'Flutter App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
...
),
);
}
}
⚠️Warning
If you use multiple other pages or alternative router structures in your application, ensure the widget's build on every page by rebuilding the structure!
Example-1 (MaterialApp.builder)
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
builder: (context, child) {
return SignForDeaf(
requestKey: 'YOUR_API_KEY',
requestUrl: 'YOUR_API_URL',
child: child!,
);
},
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
...
);
}
}
🆕 Config, On/Off, Floating Button & Tap-to-Translate
A single-object config, an on/off switch, a floating tap-to-translate button and opt-in selection-menu integration.
Behavior change: the package no longer forces all text to be selectable (the old always-on long-press menu hurt normal UX). When the SDK is off (the default) your app behaves 100% natively. Turn it on to enable the floating tap-to-translate button and the sign-language selection-menu item.
On/off (settings switch or auto by profile)
The SDK is disabled by default. Enable it from a settings toggle, or automatically:
// From anywhere below the widget:
SignForDeaf.of(context).enable(); // e.g. bound to a Switch
SignForDeaf.of(context).disable();
// Or auto-enable (e.g. based on the user's accessibility profile):
SignForDeaf(config: SignForDeafConfig(apiKey: '…', apiUrl: '…', autoEnable: true), child: …)
Sign-language item in the native selection menu (opt-in)
The package does not make your text selectable. Where your app already has selectable text, add our builder so the menu shows İşaret Dili while the SDK is enabled:
Builder(builder: (context) {
final sfd = SignForDeaf.of(context);
return SelectableText('Merhaba dünya', contextMenuBuilder: sfd.contextMenuBuilder);
// Works for TextField too.
});
Single-object config + shared controller
final controller = SignForDeafController(
storage: SharedPreferencesSignForDeafStorage(), // optional persistence
);
MaterialApp(
builder: (context, child) => SignForDeaf(
controller: controller,
config: const SignForDeafConfig(
apiKey: 'YOUR_API_KEY',
apiUrl: 'YOUR_API_URL',
language: SignLanguage.turkish, // tr/en/ar
theme: SignForDeafTheme(primaryColor: Color(0xFF6750A4)),
floatingButton: FloatingButtonConfig(hintMaxShows: 2),
),
onEvent: (e) => debugPrint('event: ${e.type}'),
child: child!,
),
home: const HomeScreen(),
);
Read state / call actions anywhere below the widget:
final c = SignForDeaf.of(context); // SignForDeafController
c.enable();
c.toggleTapToTranslate();
c.translate('Merhaba'); // programmatic translation
Tap-to-translate
When the floating button turns the mode on, tapping a sentence translates it.
Capture works by hit-testing the render tree for the paragraph under the finger.
For custom-painted or transformed text where that is unreliable, wrap it with
SignForDeafText('...') as a guaranteed-tappable fallback.
Note: Flutter renders the whole UI to a single native surface, so React Native's native per-
TextViewtap listeners don't apply here — the Dart hit-test is the Flutter-native equivalent.SelectableTextis covered too; an editableTextFieldis not, so that typing keeps working (use the selection menu there instead).
The player is the mode
The floating button does not toggle a hidden mode — it opens the player, and the player's own controls run the whole lifecycle:
| Action | Player | Tap-to-translate | Floating button |
|---|---|---|---|
| Tap the floating button | opens, idle avatar plays | on | hidden |
| Collapse ⌄ | folds to its bar | off — the app is fully native | hidden |
| Expand ⌃ | opens again | on | hidden |
| Close ✕ | closes | off | back, in its off state |
Collapsing also pauses playback: a sign language video nobody can see only spends battery. Expanding resumes where it left off.
A purely programmatic controller.translate('…') still shows the player without
opening the mode, so hosts driving the SDK from their own UI are unaffected.
Your app stays usable (smartPassthrough, default true)
Tap mode does not take the screen hostage. Each tap is classified while the hit test is still being built:
| What the user taps | Where the tap goes |
|---|---|
Plain text (Text, RichText, SelectableText) |
Translation |
| The label of a button, list row or link | Translation |
| A control with no text — icon button, switch, slider, the box of a checkbox | Your app |
TextField |
Your app (focus) |
| Scroll / drag | Your app, always |
Labels are translated because a button nobody can read is a button nobody can safely press — "I agree" is the single most important word on a contract screen. Collapsing the player hands every tap straight back to the app, which is what collapsing is for.
The same row can do both: on a CheckboxListTile, tapping the text reads it and
tapping the box ticks it.
Icons are not text. Flutter draws them as a RichText holding a private-use
codepoint, and the SDK filters those out, so icon buttons keep working and no
meaningless glyph is ever sent for translation.
Set smartPassthrough: false to restore the pre-2.0 behavior where the mode
captures every tap.
Long press (longPressToTranslate, default false)
Passthrough leaves one thing out of reach: text the host app made tappable — a list row, a link styled as text. A tap there always belongs to the app, so it can never be translated. A long press can, and it is available while the player is open and expanded.
It joins the gesture arena rather than blocking the gesture, so nothing you built is taken from you:
| Long-pressed | Result |
|---|---|
SelectableText / TextField |
selection and its toolbar, untouched |
An element with your own onLongPress |
your handler runs |
Text with only onTap |
translated |
Plain Text |
translated |
Two mechanisms do that. Over editable text the SDK declines the hit test and never enters the arena at all — winning it and then backing out would cancel the selection. Everywhere else its deadline is 600 ms against the framework's 500 ms, so any long press you registered fires first and takes the gesture. The SDK's layer sits above your app and would otherwise win a tie.
The known limit: a custom LongPressGestureRecognizer with a duration longer
than 600 ms would lose. GestureDetector and InkWell expose no duration, so
this is rare — but it is why the feature ships off by default.
Sentence granularity (granularity, default sentence)
A tap translates only the sentence under the finger, and the card offers
‹ n/m › navigation for the rest of the paragraph. Splitting is lossless and
tuned for Turkish prose — T.C., A.Ş., 5.000.000 TL,
www.example.com.tr and numbered clauses (7. Para yatırma…) do not split.
When a paragraph yields a single segment, or the tap cannot be resolved to a
character, the whole paragraph is translated instead — the pre-2.0 behavior.
Set granularity: TranslationGranularity.paragraph to always translate the
whole block.
🎨 Personalization
All customization is set through SignForDeafConfig and applied live to the UI.
Theme
SignForDeafTheme styles the floating card, the control bar and the floating
button:
theme: SignForDeafTheme(
primaryColor: Color(0xFF6750A4), // control block, pills, spinner, active button fill
textColor: Color(0xFF1C1B1F), // failure messages inside the stage
onPrimaryColor: Color(0xFFFFFFFF), // icons and caption drawn on primaryColor
surfaceColor: Color(0xFFFFFFFF), // background behind the avatar
cornerRadius: 16,
),
Contrast is enforced, not assumed. onPrimaryColor and textColor are your
preference; before painting, each is checked against the surface behind it and
replaced with black or white if it falls under the WCAG 4.5:1 minimum. A brand
color light enough to swallow white text is a realistic possibility, and an
unreadable caption in an accessibility SDK is a defect rather than a styling
choice. Debug builds log a one-line warning when a color is overridden, so the
substitution is never silent.
Translation player
SignForDeafCardConfig controls the floating player. It is non-modal: no
scrim is drawn, it takes roughly a third of the screen in a corner, and it can
be dragged anywhere or collapsed down to its control bar.
The avatar and the control bar are two independent floating blocks, not one card — a portrait signer is narrower than a row of controls, so a shared width would either letterbox the video or squeeze the controls below the minimum tap target.
card: SignForDeafCardConfig(
draggable: true,
initialCorner: CardCorner.bottomRight,
avatarHeight: 240, // width follows from the video's aspect ratio
avatarMaxWidth: 170, // ceiling, used when a video is landscape
placeholderAvatar: null, // idle clip; null follows fdid/tid (see below)
placeholderAsset: null, // your own asset, overriding the bundled clip
showSpeed: true, // the cycling 1.0x / 1.2x / 1.5x / 2.0x button
showLoop: true,
showFeedback: false, // 👍/👎 over the avatar
showContact: false, // mail button in the bar
speeds: [1.0, 1.2, 1.5, 2.0],
defaultSpeed: 1.0,
defaultLooping: true,
),
The player is two blocks — the avatar, and a primary-colored block holding the controls with the caption beneath them — and folds to a single 132×44 pt bar when collapsed. The control block is always exactly as wide as the avatar, and the collapse/close pill hangs above the top-right corner so it barely covers the video.
| Screen | Player | Share of screen |
|---|---|---|
| 393×852 | 212×332 pt | 54% × 39% |
| 375×667 | 156×280 pt | 42% × 42% |
| 360×640 | 144×269 pt | 40% × 42% |
The whole player is budgeted at 42% of the screen height, and the avatar gets
what is left after the bar, the caption and the gaps. Capping the avatar alone
would not work: its height settles at avatarMaxWidth / aspectRatio on every
screen, so the fixed chrome is what decides whether the player looks
proportionate on a short phone.
Idle avatar while loading
Instead of a spinner, an avatar loop plays while a translation is being fetched.
Four clips ship with the SDK (PlaceholderAvatar.hesna / jason / kadir / owais,
184 KB in total) and each is a boomerang — its own reverse concatenated onto
the end — so looping reads as a continuous back-and-forth motion.
video_player cannot play in reverse, so the effect is baked into the asset
rather than driven at runtime.
The loop is blurred and carries a small spinner until the real translation arrives. Without that, the idle avatar is indistinguishable from a finished translation and the user would take it for the answer.
The signer follows your tid / fdid. Each clip is a specific person, and
each person is identified on the backend by an id pair:
| Signer | tid |
fdid |
Sign language |
|---|---|---|---|
| Kadir | 23 |
16 |
TSL |
| Hesna | 43 |
35 |
TSL |
| Jason | 44 |
36 |
BSL |
| Owais | 37 |
29 |
ASL |
Left at its default (placeholderAvatar: null) the SDK loops the signer behind
the ids in use, so the idle avatar and the translation that follows it are the
same person. The backend may override the requested pair — an account can be
pinned to a different translator or dictionary than the app asked for — and the
SDK adopts the served pair from the response: the signer switches for the rest
of the session and the corrected ids go out on subsequent requests. If the two
ids disagree, tid decides, since it names the translator while fdid only
selects their vocabulary. Unknown ids fall back to Hesna.
Set placeholderAvatar explicitly to pin one signer regardless of the ids.
Point placeholderAsset at your own file to use a different avatar; declare it
in your pubspec.yaml, since it is resolved against the host app's bundle.
Set it to '' to go back to a plain spinner. If a clip cannot be played for any
reason, the spinner is used automatically — the card never breaks over a
decorative loop.
The SignForDeaf mark sits in the avatar's top-left corner whenever the player is open, and takes the play button's place in the collapsed bar.
The sentence being translated is shown under the controls, inside the same block, never over the signer. It appears as soon as a translation starts — so the user can confirm what they tapped before the video arrives — and is capped at two lines. The cap follows the system text scale, so raising it grows the block rather than cropping the words.
A sentence that fits sits centred and still. A longer one scrolls itself: it holds at the top long enough to be read into, travels down slowly, pauses at the end and returns. A caption the reader has to drag is a caption most readers never finish. Touching it hands control straight back — auto-scrolling stays out of the way for a few seconds after any manual scroll.
Long text (maxSegmentChars, default 900)
A tap normally translates one sentence, so length rarely matters. A run-on with
no punctuation is the exception, and it is subdivided at clause boundaries —
punctuation first, then coordinating conjunctions (ve, veya, ancak,
çünkü…), with a word boundary as a last resort. Splits land near the middle of
an over-long run so the chunks stay balanced.
This is a transport guard, not a style choice. The text travels as the s
query parameter of a GET request, and the most conservative common server
ceiling for a URL is 2048 characters — of which Turkish text consumes roughly
two per character once percent-encoded. Past that the request fails at the
gateway with nothing to show the user. The limit applies in both granularity
modes for the same reason.
Sign language is not a word-for-word transcoding, so chunking prefers clause boundaries rather than a fixed word count: those are natural pauses in signing too. Ordinary prose never reaches the limit — the contract clauses in the example app are 190–250 characters.
There is no on-screen sentence navigation: the page itself is the navigation,
since smart passthrough makes tapping the next sentence a single gesture. The
next sentence is still fetched in the background as soon as the current one
resolves, so that tap normally resolves from cache instead of a fresh round
trip. SignForDeafController.nextSegment / previousSegment remain public if
you want to build your own controls.
Speed is a single button showing the active value; tapping it advances to the
next speed and wraps around. The chosen speed and loop setting are persisted
through SignForDeafStorage and restored on the next launch. Both stay usable
while a translation is still loading — they are preferences that apply the
moment playback starts.
showFeedback and showContact are off by default: they compete with the
avatar for space and the endpoints they report to are not wired up yet (see
ApiServices.feedbackPath).
Language
The active languages (tr/en/ar) drive every label, menu item and the API language code
(tr=1 … ar=6):
language: SignLanguage.turkish, // tr | en | ar (de/fr/es coming soon)
Floating button
floatingButton: FloatingButtonConfig(
enabled: true,
size: 44,
idleBehavior: FloatingButtonIdleBehavior.peek, // peek | fade | none
idleDelayMs: 2500,
hintMaxShows: 2, // persisted across launches (see storage)
// Optional color overrides (default to theme.primaryColor):
// backgroundColor, activeBackgroundColor, iconColor, activeIconColor, borderColor
),
The onboarding hint ("tap on any text to translate it") shows for the first
hintMaxShows activations, then hides for good — persisted when you pass a
SharedPreferencesSignForDeafStorage() to the controller.
Accessibility
SignForDeafAccessibility adds screen-reader support to the sheet:
accessibility: SignForDeafAccessibility(
announceOnOpen: true, // announce when the sheet opens (VoiceOver/TalkBack)
announceOnClose: false,
videoPlayerLabel: 'Sign language video is playing',
closeButtonLabel: 'Close',
bottomSheetHint: 'Sign language translation',
),
Full config:
SignForDeafConfig(
apiKey: 'YOUR_API_KEY',
apiUrl: 'YOUR_API_URL',
language: SignLanguage.turkish,
fdid: '16',
tid: '23',
theme: SignForDeafTheme(...),
floatingButton: FloatingButtonConfig(...),
accessibility: SignForDeafAccessibility(...),
autoEnable: false, // start disabled by default
);
🔒 Protecting Sensitive (Personal) Data
Whenever a translation is requested (via the selection menu, tap-to-translate,
or translate()), the selected text would be sent to the translation server. To
make sure personal data (T.C. Kimlik No, credit card, phone, e-mail, IBAN…) is
never sent to or processed by the server, two protection layers are built
in.
1. Mark sensitive content (opt-in)
Wrap any text-bearing widget with SignForDeafSensitive. The text stays visible
(and behaves exactly as your app renders it), but a sign-language translation
request for it is blocked — no request is sent.
SignForDeafSensitive(
child: Text('T.C. Kimlik No: 12345678901'),
)
2. Automatic detection (safety net)
Even if you forget to mark a field, selected text that matches a T.C. Kimlik No (checksum-validated), credit card number (Luhn-validated), Turkish IBAN, e-mail, or GSM phone number is automatically detected and blocked before any request leaves the device.
When content is blocked, the user sees a short warning
("Bu içerik hassas veri içerdiği için işaret diline çevrilemez." /
"This content contains sensitive data and cannot be translated.") and the
/Translate endpoint is never called.
Example-2 (AutoRoute)
Route Config
@AutoRouterConfig()
class AppRouter extends $AppRouter {
@override
List<AutoRoute> get routes => [...routesList];
static PageRouteBuilder signForDeafBuilder(BuildContext context, Widget child,
Page<dynamic> page, RouteTransitionsBuilder transitionsBuilder) {
return PageRouteBuilder(
settings: page,
pageBuilder: (_, __, ___) {
return SignForDeaf(
requestKey: 'YOUR_API_KEY',
requestUrl: 'YOUR_API_URL',
child: child!,
);
},
transitionsBuilder: transitionsBuilder,
);
}
}
Route List
List<AutoRoute> routesList = [
CustomRoute(
page: Route.page,
customRouteBuilder: (context, child, page) => AppRouter.signForDeafBuilder(
context, child, page, TransitionsBuilders.fadeIn),
),
];
📸 Screenshots
Left → right: native app (SDK off) · SDK enabled with the floating button · sensitive-data blocking