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.

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 SignForDeaf inside MaterialApp.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-TextView tap listeners don't apply here — the Dart hit-test is the Flutter-native equivalent. SelectableText is covered too; an editable TextField is 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

Showcase — SDK off, the app is fully native   SDK enabled — floating tap-to-translate button   Sensitive data is blocked before it leaves the device

Left → right: native app (SDK off) · SDK enabled with the floating button · sensitive-data blocking