Luqta Flutter SDK
Official Flutter SDK for the Luqta platform — integrate contests, quizzes, rewards, and gamification into your Flutter app in minutes.
Version: 1.8.0
Table of Contents
- Features
- Installation
- Quick Start
- Usage Modes
- Configuration
- API Reference
- Level Types
- Date Handling
- Error Handling
- Availability Checks
- Complete Examples
- Support
Features
- Two Integration Modes — Preconfigured (instant full UI) or Custom (API-only, bring your own UI)
- Contest Management — Browse, participate, track progress, private contest access codes
- Level Completion — Text, QR code, Link, Image, Quiz, Client Webhook, Luqta Webhook
- Geolocation & AR Levels — Hunt-point map with live distances and geofencing, plus a bundled 3D AR hunt. Location watching, permission prompts and the AR engine all ship inside the SDK — see Platform setup
- Survey & Link-Task Levels — Multi-question forms; a link with a server-enforced dwell
- Referral Levels — Invite friends with a code your app turns into its own link
- Availability Checks — Automatic level/contest start/end date gating with user-facing alerts
- Smart Date Parsing — Handles both ISO-8601 strings and numeric epoch timestamps (ms or s)
- Profile Icon — Optional draggable floating profile button with full stats bottom sheet
- Auto Token Refresh — Expired tokens are silently refreshed and requests retried once
- Auto User Sync — First-time users are auto-synced if
userProfileis provided in config - Zero Storage Mode — No PII stored; all identifiers hashed server-side with SHA-256
- Multi-language — English and Arabic built-in; full RTL layout support
- Beautiful Pre-built UI — Contest carousel, detail screens, shimmer loading, toast notifications
- Secure — Rate limiting, request deduplication, secure token storage
Installation
Add to your pubspec.yaml:
dependencies:
luqta_sdk: ^1.8.0
Then run:
flutter pub get
Import in your Dart files:
import 'package:luqta_sdk/luqta_sdk.dart';
Platform setup
Nothing to do for text, link, image, quiz or webhook levels. The steps below apply only if your contests include QR, geolocation or AR levels.
Everything the SDK can ship for you, it does — the 8th Wall AR engine is bundled
inside the package, and the SDK asks for permissions at the right moment on its
own. What it cannot do is declare permissions on your behalf: those live in your
app's manifest and Info.plist, and only you can put them there.
Android — android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Geolocation levels. The bundled geolocation plugin declares nothing of
its own, so without these the OS refuses to even show the prompt. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- AR levels: the engine reads high-rate motion sensors. -->
<uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" />
<application
...
android:hardwareAccelerated="true"
android:largeHeap="true"
<!-- AR levels: the engine is served from a loopback HTTP server inside
the app, and Android 9+ blocks cleartext by default. -->
android:usesCleartextTraffic="true">
...
</application>
<!-- Geolocation levels. Android 11+ hides other packages, so without this
the "Go to Direction" button has no visible maps app to launch. -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
</manifest>
CAMERA is not listed: the bundled scanner plugin declares it itself and it
merges into your manifest automatically.
iOS — ios/Runner/Info.plist
<!-- Geolocation levels -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to show how far you are from each hunt point.</string>
<!-- QR and AR levels -->
<key>NSCameraUsageDescription</key>
<string>The camera is used for the AR hunt and for scanning QR codes.</string>
<!-- AR levels -->
<key>NSMotionUsageDescription</key>
<string>Device motion positions 3D objects during the AR hunt.</string>
<!-- Image-submission levels -->
<key>NSPhotoLibraryUsageDescription</key>
<string>Your photo library is used to submit a picture for photo levels.</string>
iOS terminates the app the moment a permission is requested without its usage description, so a missing key here is a crash rather than a warning.
If you get it wrong
The SDK detects a missing declaration and prints the exact fix to the log:
Luqta SDK: geolocation levels need location permissions declared by the host app.
Android — android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
...
The player sees a plain "Location is unavailable" notice rather than a prompt loop they cannot escape.
What you do not need to do
- Do not copy the AR engine into your app's assets. It ships inside
luqta_sdkand is bundled automatically; a second copy adds ~23 MB to your APK and is never read. - Do not request permissions before opening a level. The SDK prompts when a geolocation or AR level is actually started.
- Do not track the player's position yourself. The SDK watches location for the duration of the hunt screen and stops when it closes.
Quick Start
Minimum Setup
// 1. Create client
final client = LuqtaClient(
config: LuqtaConfig(
apiKey: 'your-api-key',
appId: 'your-app-id',
),
);
// 2. Initialize SDK
await client.initializeSdk();
// 3. Initialize user (first-time: sync + initialize in one call)
await client.syncAndInitializeUser(UserProfile(
name: 'John Doe',
email: 'john@example.com',
policyAccept: true,
));
// Done — use client.contests, client.levels, etc.
Usage Modes
| Mode | Best For | Effort |
|---|---|---|
| Preconfigured | Quick integration, standard UI | ~10 lines |
| Custom | Full UI control, custom designs | More work |
1. Preconfigured Mode (Easiest)
One call — render() — returns a complete, production-ready Widget tree. The SDK handles all screens, navigation, data fetching, and state management internally.
import 'package:flutter/material.dart';
import 'package:luqta_sdk/luqta_sdk.dart';
class LuqtaPage extends StatefulWidget {
const LuqtaPage({super.key});
@override
State<LuqtaPage> createState() => _LuqtaPageState();
}
class _LuqtaPageState extends State<LuqtaPage> {
LuqtaClient? _client;
bool _ready = false;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
_client = LuqtaClient(
config: LuqtaConfig(
mode: LuqtaMode.preconfigured,
apiKey: 'your-api-key',
appId: 'your-app-id',
production: false, // set true for production
locale: 'en', // 'en' or 'ar'
rtl: false,
branding: BrandingConfig(
primaryColor: Color(0xFF5304FB),
),
// Optional: show floating profile button
showProfileIcon: true,
// Optional: auto-sync first-time users
userProfile: UserProfile(
name: 'John Doe',
email: 'john@example.com',
policyAccept: true,
),
onAction: (action) => debugPrint('Action: ${action['type']}'),
onError: (error) => debugPrint('Error: $error'),
),
);
await _client!.initializeSdk();
await _client!.syncAndInitializeUser(UserProfile(
email: 'john@example.com',
policyAccept: true,
));
setState(() => _ready = true);
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(body: _client!.render());
}
}
What render() gives you
- Contest list — a horizontal carousel by default, or one vertical scrolling
list with
contestsLayout: ContestsLayoutMode.vertical - Contest detail pages with level list
- Level completion flows (text, QR, link, image, quiz, webhook)
- Private contest access-code bottom sheet
- Progress tracking and congratulation dialogs
- Level/contest availability alerts (not started yet, already ended)
- Draggable profile icon with stats sheet (if
showProfileIcon: true) - Notifications bell and list for signed-in players
- All navigation and animations
LuqtaConfig(
mode: LuqtaMode.preconfigured,
// ...
// Every contest in one scrolling list rather than behind a swipe. The
// "View All" link is not shown — there is nothing left for it to open.
contestsLayout: ContestsLayoutMode.vertical,
)
Contest detail, levels and completion are identical either way; only the first screen changes.
Opening one contest directly
Some hosts show their own contest carousel, for example on a home page, using the
SDK's ContestsScreen. When a player taps a card there, pass that contest to
render() so the SDK opens its detail page instead of the full list:
ContestsScreen(
initialContests: contests,
showSeeAllButton: false,
onContestTap: (contest) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => Scaffold(body: client.render(initialContest: contest)),
));
},
)
The contest from the carousel is enough. The detail screen fetches levels and
progress itself, exactly as a tap inside the SDK's own list would. A private
contest still goes through its access-code sheet first. Call render() with no
argument to open the list. LuqtaUIRenderer takes the same initialContest.
Added in 1.8.0. Before this, a host could only open the SDK on its list, so a tap on one contest dropped the player back on every contest.
Notifications
A signed-in player sees a bell in the contests header. It shows a dot when a notification is unread, and tapping it opens a sheet with the player's notifications, newest first, in the player's language. Nothing needs to be configured.
The bell only appears once the SDK holds a user token. Notifications belong to one player, and the endpoint behind them needs that token, so a signed-out player sees no bell.
Fixed in 1.8.0. Earlier versions never drew the bell, and called notification paths the backend does not have, so every request failed with "Unable to locate request".
2. Custom Mode (Full Control)
Use the SDK as a pure API layer and build your own UI.
Setup
final client = LuqtaClient(
config: LuqtaConfig(
apiKey: 'your-api-key',
appId: 'your-app-id',
),
);
await client.initializeSdk();
await client.syncAndInitializeUser(UserProfile(
email: 'user@example.com',
policyAccept: true,
));
API Services
// Contests
final response = await client.contests.getAll();
final contests = response.data.items
.map((e) => Contest.fromJson(e))
.toList();
await client.contests.participate(123);
await client.contests.participate(123, accessCode: 'PRIV-CODE');
await client.contests.compete(123); // mark completed
// Levels
await client.levels.complete(456, data: {'textContent': 'my answer'});
await client.levels.complete(456, data: {'qrCode': 'scanned-value'});
await client.levels.complete(456, data: {'link': 'https://example.com'});
await client.levels.completeWithImage(456, 'https://cdn.example.com/photo.jpg');
await client.levels.completeClientWebhook(456, variables: [
{'variable_name': 'email', 'value': 'user@example.com'},
]);
// Referral levels — the sharer gets a code, the friend redeems it.
final invite = await client.referrals.share(levelId);
final progress = await client.referrals.progress(levelId);
await client.referrals.redeem(invite.referralCode);
// Quiz
final quiz = await client.quiz.start(quizId, levelId);
final result = await client.quiz.submitAnswer(attemptId, questionId, optionId, levelId);
// Profile
final profile = await client.profile.get();
// Rewards
final rewards = await client.rewards.getAll();
// Notifications: parsed, newest first
final notifications = await client.notifications.list();
for (final n in notifications) {
print('${n.titleFor('en')} read=${n.isRead}');
}
await client.notifications.markAsRead([notificationId]);
Using individual widgets
// Ready-made contest list screen
ContestsScreen(
locale: 'en',
showSeeAllButton: true,
onFetchContests: ({required int page, required int limit}) async {
final res = await client.contests.getAll();
return res.data.items.map((e) => Contest.fromJson(e)).toList();
},
onContestTap: (contest) => _openDetail(contest),
)
// Contest detail screen
ContestDetailScreen(
contest: contest,
locale: 'en',
onBack: () => Navigator.pop(context),
onFetchContestDetails: (id) async {
final res = await client.getContestDetailsProgress(id);
return Contest.fromJson(res['data']);
},
onJoinContest: (id, accessCode) async {
await client.contests.participate(id, accessCode: accessCode);
final res = await client.getContestDetailsProgress(id);
return Contest.fromJson(res['data']);
},
onCompleteLevel: (levelId, type, {
textContent, qrCode, link, imageUrl, webhookPayload,
}) async {
final res = await client.levels.complete(levelId, data: {
if (textContent != null) 'textContent': textContent,
if (qrCode != null) 'qrCode': qrCode,
if (link != null) 'link': link,
});
return res['success'] == true;
},
)
Configuration
LuqtaConfig Options
| Parameter | Type | Default | Description |
|---|---|---|---|
apiKey |
String |
required | Your Luqta API key |
appId |
String |
required | Your application ID |
mode |
LuqtaMode |
custom |
custom or preconfigured |
production |
bool |
false |
Use production endpoint |
user |
LuqtaUser? |
null |
User identification |
userProfile |
UserProfile? |
null |
Full profile for auto-sync on first run |
branding |
BrandingConfig? |
null |
UI branding (preconfigured mode) |
locale |
String |
'en' |
'en' or 'ar' |
rtl |
bool |
false |
Right-to-left layout |
showProfileIcon |
bool |
false |
Show draggable profile button |
contestsLayout |
ContestsLayoutMode |
horizontal |
Carousel, or one vertical list (preconfigured mode) |
zeroStorage |
bool |
false |
Hash all PII server-side |
timeout |
Duration |
30s |
HTTP request timeout |
headers |
Map<String, String>? |
null |
Extra HTTP headers |
onAction |
Function? |
null |
Action event callback |
onError |
Function? |
null |
Error event callback |
User Identification
Any one of the four is enough. Priority for hash lookup: uuid →
username → email → phoneNumber. Email and phone are format-checked when
given — a malformed one throws INVALID_EMAIL_FORMAT or
INVALID_PHONE_FORMAT from the constructor, before any request is made.
Fixed in 1.7.0. Earlier versions refused a username-only or uuid-only user with
MISSING_USER_INFO, despite documenting them here.
// by email
LuqtaUser(email: 'user@example.com')
// by phone (international format)
LuqtaUser(phoneNumber: '+923001234567')
// by username
LuqtaUser(username: 'john_doe')
// by UUID (v4)
LuqtaUser(uuid: '550e8400-e29b-41d4-a716-446655440000')
// multiple identifiers
LuqtaUser(
email: 'user@example.com',
username: 'john_doe',
uuid: '550e8400-e29b-41d4-a716-446655440000',
)
Branding
BrandingConfig(
primaryColor: Color(0xFF5304FB),
secondaryColor: Color(0xFF8F67FD),
backgroundColor: Colors.white,
textColor: Colors.black87,
borderRadius: 12.0,
fontFamily: 'Poppins, sans-serif',
)
primaryColor alone is enough. Every screen the SDK draws — buttons,
badges, level icons, focus rings, the QR scanner frame, the congratulations
badge — reads its brand colour from the theme, and screens the SDK pushes as
their own routes (the quiz, AR and locations levels, the QR scanner, the
congratulations screen, the link task's WebView) re-apply that theme so they are
not styled by your app instead.
Leave secondaryColor out and the accent for gradients is derived from your
primary — same hue, deeper. Set it only when you want a specific second
colour; Luqta's own pair is used only when you give no colours at all.
Localization & RTL
LuqtaConfig(
locale: 'ar', // Arabic
rtl: true, // right-to-left layout
)
Use translations in custom UI:
final label = LuqtaTranslations.translate('contests.title', locale: 'ar');
// → "المسابقات"
Zero Storage Mode
When enabled, the backend hashes every PII field using SHA-256 — no plaintext is ever written to any column.
LuqtaConfig(
apiKey: 'your-api-key',
appId: 'your-app-id',
zeroStorage: true,
)
await client.syncAndInitializeUser(UserProfile(
email: 'user@example.com',
name: 'John Doe',
));
// → Only sdk_hash_user is stored; all PII columns remain null.
Re-syncing with zeroStorage: true automatically clears any previously stored plaintext PII.
Profile Icon
When showProfileIcon: true and the user is initialized, a draggable circular button appears (bottom-right corner). Tapping it opens a bottom sheet showing the user's name, contact info, total points, and contest statistics.
LuqtaConfig(
showProfileIcon: true,
// other options …
)
The icon is only rendered in preconfigured mode (render()). In custom mode, call client.profile.get() directly and build your own profile UI.
API Reference
Client Methods
| Method | Description |
|---|---|
initializeSdk() |
Obtain SDK-level auth token |
initializeUser() |
Obtain user-level auth token (requires prior syncUser) |
syncUser(profile) |
Push user data to backend |
syncAndInitializeUser(profile) |
Sync and initialize in one call (recommended for first-time users) |
tryRestoreSession() |
Rehydrate tokens from secure storage (called automatically) |
isInitialized() |
Returns true when user token is active |
hasUserToken() |
Async check for persisted user token |
render() |
Returns pre-built UI Widget (preconfigured mode only) |
getContestDetailsProgress(id) |
Fetch contest with user progress (dual-auth endpoint) |
getContestDetailsApp(id) |
Fetch contest details (SDK-only endpoint) |
post(endpoint, body) |
Low-level authenticated POST |
API Services
// client.contests
client.contests.getAll()
client.contests.participate(id, {accessCode})
client.contests.compete(id)
// client.levels
client.levels.complete(levelId, data: {...})
client.levels.completeWithImage(levelId, imageUrl)
client.levels.completeClientWebhook(levelId, variables: [...])
// client.quiz
client.quiz.start(quizId, levelId)
client.quiz.submitAnswer(attemptId, questionId, optionId, levelId)
// client.profile
client.profile.get()
// client.rewards
client.rewards.getAll()
client.rewards.redeem(rewardId)
// client.notifications
client.notifications.list() // List<LuqtaNotification>, parsed
client.notifications.getAll() // raw response map
client.notifications.markAsRead(ids) // List<int>
UserProfile Fields
UserProfile(
email: 'user@example.com',
phoneNumber: '+923001234567',
name: 'John Doe',
username: 'john_doe', // → sdk_username
userId: 'ext-123', // → sdk_user_id
uuid: '550e8400-...',
country: 'PK',
gender: 'male',
dob: '1990-01-01',
verified: true,
policyAccept: true,
interestedIn: ['sports', 'tech'],
imageUrl: 'https://cdn.example.com/avatar.jpg',
)
Level Types
levelType |
Completion data | Description |
|---|---|---|
text |
{'textContent': '...'} |
Free-text answer |
qr |
{'qrCode': '...'} |
Scanned QR value |
link |
{'link': 'https://...'} |
URL submission |
image |
imageUrl (use completeWithImage) |
Photo upload |
quiz |
Handled by quiz flow | Interactive quiz |
client_webhook |
variables: [...] (use completeClientWebhook) |
Custom form POSTed to client URL |
luqta_webhook |
Automatically handled | Opens Luqta-hosted URL |
level_geolocation |
Handled by the hunt flow | Map of hunt points + 3D AR hunt |
survey |
Handled by the survey flow | A set of questions, answered in the sheet |
link_task |
Handled by the link-task flow | Open a link and stay on the page for a set time |
level_referral |
Handled by the referral flow | Invite friends; completes when enough of them redeem |
Blur level description
A level can hide its description until it opens. The portal's Blur Level
Description switch sets blur_description on the level, and the SDK blurs the
text on the level row while the level has not started yet — it clears itself the
moment start_date passes, with no refresh needed.
The flag on its own is not the answer. Blurring forever would be a description nobody ever reads, so the rule is:
blurred = blur_description AND now <= start_date
which is what the main Luqta app does
(is_blur_description && !isTimeExpired(start_date)). A level with no start
date is never blurred — there would be no moment at which the text was
revealed. An unreadable start date is treated the same way, as no constraint.
While it is blurred, the See More / See Less toggle is hidden too: there is nothing to expand.
Level.blurDescription carries the flag and Level.shouldBlurDescription()
applies the rule, so a custom-mode host renders the same thing the SDK does.
QR auto-completion
A printed Luqta QR does not encode the level's answer — it encodes a link
carrying levels.uid. A player points their phone's own camera at it, your
app opens, and the level completes: no in-app scanner, no contest screen, no
hunting for which level the code belonged to. The level is resolved server-side,
because the client cannot know it — the camera handed over a link, not a
contest.
The link is yours, not Luqta's: a luqta.io link would open Luqta, not your app. Register your own domain or scheme, print the code, and hand the SDK whatever the link carried.
// In your deep-link handler, after sign-in and initializeUser
try {
final result = await client.levels.completeQrByUid(deepLink.toString());
// result.levelId, result.contestId, result.pointsEarned
} catch (e) {
showMessage(qrRefusalMessage(e, locale: locale));
}
Order matters. The scan is credited to whoever the SDK session names at that
moment, so run it after your own sign-in and after initializeUser. A scan that
arrives while the app is signed out should be held — stash the uid, sign the
user in, then redeem it.
The uid reader accepts the bare uid, ?qr_uid=, ?uid=, ?id=, ?code=,
?qrUid=, or a trailing /qr-scan/<uid> path segment.
The in-app scanner keeps working. One printed code has to serve both ways of
scanning it, so the SDK's scanner can find itself holding the auto-completion
link rather than the level's own qrData. It takes the uid route when the
scanned text names a uid in a URL query string, and the ordinary route
otherwise — a level's qrData is free text and is allowed to be a URL of its
own. In preconfigured mode there is nothing to wire.
Refusals carry a code: INVALID_QR_CODE, NOT_A_QR_LEVEL, NOT_PARTICIPANT,
LEVEL_NOT_STARTED, LEVEL_ENDED, LEVEL_ALREADY_COMPLETED. A scan never
auto-joins a contest — that is not what a scan consents to, and a private
contest has an access code the scan does not carry.
Full contract and the backend side: QR_AUTOCOMPLETION_SPEC.md.
Survey and link-task levels
A survey level and a link-task level both take a sheet of their own, and both need nothing from the host app — in preconfigured mode they just play.
Survey (survey / level_survey) asks a set of questions: single-select,
multi-select and free text, each required or optional, with a minimum number of
answers before the level can be submitted and an authored order that the portal
can randomise. Two rules are worth knowing because they are invisible from the
outside:
- A skipped question sends no entry at all. That is how a skip is encoded — not as an answer of nothing — so an optional question left alone simply does not appear in the payload.
- The option stored is always the English one, whatever language the player read. Otherwise the same choice lands in two different buckets in the responses grid depending on the player's locale. A partial Arabic translation is refused whole rather than shown with gaps, because a short list shifts the index mapping that maps a tapped label back to its English option.
Link task (link_task / level_link_task / linktask) is a link the
player opens and has to stay on for a set time. It is not the plain link
level, which completes the moment the link is opened.
The destination opens in a WebView the SDK owns. That is the only mode that
can actually be measured: the SDK knows the page rendered, knows the player is
looking at it, and knows the moment they leave. Set the level's
link_open_mode to external to hand it to the browser instead, where the only
signal left is time spent away from the app.
The dwell starts when the page reports it finished loading, not when the button was tapped — a slow network would otherwise have the player staring at a blank WebView while the timer ran down. A main-frame error, or thirty seconds of silence after loading begins, ends the attempt with an explanation rather than counting against a blank page.
The claim is made on that page. When the timer reaches zero the page stays up and offers the claim there; closing it once the time is served is itself the claim. Mid-timer, closing asks first.
The countdown on screen is presentation only. The server stamps the open time and re-derives the elapsed time when the level is completed, so a client that shortened or skipped its countdown is refused there — which is also why a failed start still counts down locally rather than dead-ending on a spinner.
Leaving restarts the timer. The dwell has to be served in one sitting. Resuming was the original behaviour and it made the dwell meaningless: open the link, close it at once, come back later, and the level was claimable without the player ever having stayed on the page. A momentary interruption — the notification shade, a rotation, an incoming call — only stops the clock.
A dwell under 10 seconds is rounded up to 10. A level saved before the timer was required holds 0, which would show "0:01", flip to done on the first tick, and read as broken rather than fast.
The URL is in task_url, not link. The admin portal writes a link task's
destination to task_url; link belongs to the older plain link level and is
empty on a link task. Level.resolvedTaskUrl reads task_url first and falls
back to link for older workspaces. Both the level output and
level/link/start must carry it — the start response is what makes a level
openable whose list output was missing the field:
final start = await client.levels.startLinkTask(level.id);
start.dwellSeconds; // int? — seconds owed to the page
start.taskUrl; // String? — the destination the server holds
start.opensInApp; // bool — false only when the level says `external`
Full contract and the backend side: XANO_SURVEY_LINKTASK_SPEC.md.
In custom mode both need levelsApi passed to ContestDetailScreen, the same
way geolocation levels do:
ContestDetailScreen(
contest: contest,
levelsApi: client.levels, // survey, link-task and geolocation levels
referralApi: client.referrals,
// ...
)
Referral levels
A referral level is the only level type a player cannot complete on their own: it advances when a friend redeems their code. Part of that flow happens in your app, not in the SDK.
The SDK hands out an 8-digit code. You turn it into a link, because the link has to open your app — a link to luqta.io would send the friend to the wrong place, and the SDK has no idea what your URL looks like.
LuqtaConfig(
apiKey: 'lq_dev_app_xxx',
appId: 'your-app-id',
referralLinkBuilder: (code) => 'https://myapp.example/invite?ref=$code',
// The SDK ships no share dependency, so without this the referral screen
// copies the invite to the clipboard instead.
onShareReferral: (text) => Share.share(text),
)
Then, in your deep-link handler — after the friend has signed in and
initializeUser has run, because the call attributes the referral to whoever
the SDK session names at that moment:
final code = ReferralApi.parseCode(deepLink.toString());
final result = await client.referrals.redeem(code);
That single call is what moves the sharer's level forward. parseCode accepts a
bare code, a code with spaces or dashes, or the whole link — it reads a link
through its query string rather than by scraping digits, so a host domain with a
number in it cannot corrupt the code.
A friend who does not have your app installed will land in a store first and open the app with no deep link attached. Keep the code from the install referrer or a deferred-deep-link provider, and redeem once they sign in.
The sharer's side needs no wiring in preconfigured mode. In custom mode, pass
referralApi and referralLinkBuilder to ContestDetailScreen the same way
levelsApi is passed.
Full contract, refusal codes and the backend side: REFERRAL_LEVEL_SPEC.md.
Geolocation levels
A geolocation level is a list of hunt points, each with coordinates and a radius. Levels arrive from the API, so a contest can add or change them without an app release — nothing about them is compiled into your build.
In preconfigured mode there is no wiring to do. client.render() dispatches
a level_geolocation level to the hunt flow on its own and hands it the API
client it needs. Your app initializes the SDK and that is the whole integration:
final client = LuqtaClient(
config: LuqtaConfig(
mode: LuqtaMode.preconfigured,
apiKey: 'your-api-key',
appId: 'your-app-id',
),
);
await client.initializeSdk();
await client.syncAndInitializeUser(UserProfile(email: 'user@example.com'));
return client.render(); // geolocation levels included
The SDK renders the "Choose Location" screen (map with every geofence drawn, plus a per-point distance that updates live as the player walks), the first-run "How to Play" interstitial, and the AR hunt itself. It also owns the location watcher, the permission prompts, and the AR engine's assets.
Only Platform setup is on you.
Custom mode. Push the screen yourself and pass a LevelsApi:
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => LevelLocationsScreen(
level: level, // levelType == 'level_geolocation'
levelsApi: client.levels,
locale: 'en',
),
));
Behaviour
- The player must be inside the point's radius to start a hunt. There is one
gate, in
LevelLocationsScreen, and every route into the AR view runs through it. point_areas.is_location_fixed: falseturns the gate off — the level is not tied to its coordinates, so the map and distances are hidden too. Absent meanstrue: every contest written before the flag existed is location-bound.point.direction.enabled: truemakesdirection.targetthe navigation target instead of the point's own coordinates. The radius is still the point's own.- A level with several points stays open until the server reports
is_level_completed; winning one point does not close it.
The SDK watches position on its own. Pass userLat / userLng to
LevelLocationsScreen only if your app already tracks the player and you want
the SDK to use your fix instead.
The AR hunt
Once the player is in range, the hunt itself is a 3D web AR scene — the 8th
Wall world-effect build, which ships inside luqta_sdk. There is no 8th Wall
account to configure, no key to supply, and nothing fetched over the network at
runtime: the SDK serves the engine from its own bundled assets over a loopback
HTTP server and renders the HUD natively on top.
Three hunt modes, chosen per level by point_areas.ar_type:
ar_type |
How it plays | Timed |
|---|---|---|
scan_find |
Objects scattered around the player — find and tap enough of them | 120s |
find_gift |
A single gift that drifts and hides — find it and tap once | untimed |
catch_logos |
Logos falling in lanes — catch them before they land | 120s |
Everything about a scene — how many objects, how far they spawn, how long a
round lasts, even a custom .glb model — comes from point_areas.ar_config on
the level, so a contest can retune a hunt without an app release. Every field is
optional and falls back to that mode's defaults.
Full field reference, engine architecture, the JavaScript bridge messages and AR troubleshooting are in API_REFERENCE.md → Geolocation & AR.
AR levels need
android:usesCleartextTraffic="true"— Android 9+ blocks the loopback request the engine is served over. See Platform setup.
Date Handling
The SDK handles dates returned by the backend in either format:
| Format | Example |
|---|---|
| ISO-8601 string | "2026-05-01T12:00:00.000Z" |
| Epoch milliseconds (Number) | 1746388800000 |
| Epoch seconds (Number) | 1746388800 |
All date fields on Level and Contest are stored as String? internally. Use LevelDateParser (or the built-in availability checks) to work with them:
// Check whether a level is currently available
final available = LevelDateParser.isLevelAvailable(level);
// Parse a raw date string to DateTime
final dt = LevelDateParser.parseDate(level.endDate);
Availability Checks
The SDK automatically evaluates startDate, endDate, and levelTimeline for both levels and contests. When a level has not started yet or has already ended, the pre-built UI shows a localized alert dialog instead of the action button. Contest detail screens similarly gate access when a contest is not active.
In custom mode, check availability manually using the date fields on the model:
// Parse a raw date string/epoch to DateTime
DateTime? parseDate(String? raw) {
if (raw == null || raw.isEmpty) return null;
final ms = int.tryParse(raw);
if (ms != null) {
// Distinguish milliseconds (13-digit) from seconds (10-digit)
return ms > 1e11
? DateTime.fromMillisecondsSinceEpoch(ms)
: DateTime.fromMillisecondsSinceEpoch(ms * 1000);
}
return DateTime.tryParse(raw);
}
// Level availability
final now = DateTime.now();
final start = parseDate(level.startDate);
final end = parseDate(level.endDate ?? level.levelTimeline);
if (start != null && now.isBefore(start)) {
showDialog(/* Level has not started yet */);
} else if (end != null && now.isAfter(end)) {
showDialog(/* Level has ended */);
}
// Contest availability
if (contest.status == 'completed') {
// show "contest ended" message
}
Error Handling
Try / catch
try {
await client.contests.participate(contestId);
} on LuqtaError catch (e) {
print('${e.code}: ${e.message} (HTTP ${e.status})');
} catch (e) {
print('Unexpected error: $e');
}
Changed in 1.7.0. Before this release the package exported a second, identically named
LuqtaErrorthat nothing ever threw, so the type check above was always false ande.codewas unreachable — integrators were left parsingtoString(). The exported class is the thrown one now. Its constructor is positional,LuqtaError(message, code, status: ..., payload: ...), so only code that constructed one needs a change.
Show e.message, never e.toString(): the latter renders as
LuqtaError: ... (Code: ...) Payload: {...}, which is not something to put in
front of a player.
onError callback
LuqtaConfig(
onError: (error) {
if (error is LuqtaError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.message)),
);
}
},
)
Token expiry
The SDK detects expired tokens (ERROR_FATAL + "token expired") and silently re-initializes before retrying the failed request once. No action required in your code.
Common Error Codes
| Code | Description |
|---|---|
MISSING_API_KEY |
API key not provided |
MISSING_APP_ID |
App ID not provided |
INVALID_EMAIL_FORMAT |
Invalid email format |
INVALID_PHONE_FORMAT |
Phone not in international format |
RATE_LIMIT_EXCEEDED |
Too many requests |
REQUEST_FAILED |
HTTP request failed |
SDK-USER-001 |
User must be synced before initializing |
ERROR_FATAL |
Fatal server-side error (often token expiry) |
Complete Examples
Example 1: Minimal preconfigured
import 'package:flutter/material.dart';
import 'package:luqta_sdk/luqta_sdk.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: LuqtaPage());
}
class LuqtaPage extends StatefulWidget {
const LuqtaPage({super.key});
@override
State<LuqtaPage> createState() => _LuqtaPageState();
}
class _LuqtaPageState extends State<LuqtaPage> {
LuqtaClient? _client;
bool _ready = false;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
_client = LuqtaClient(
config: LuqtaConfig(
mode: LuqtaMode.preconfigured,
apiKey: 'YOUR_API_KEY',
appId: 'YOUR_APP_ID',
),
);
await _client!.initializeSdk();
await _client!.syncAndInitializeUser(
UserProfile(email: 'user@example.com', policyAccept: true),
);
setState(() => _ready = true);
}
@override
Widget build(BuildContext context) {
if (!_ready) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(body: _client!.render());
}
}
Example 2: Embed in existing app
class HomePage extends StatelessWidget {
final LuqtaClient client;
const HomePage({super.key, required this.client});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My App')),
body: Column(
children: [
const MyCustomHeader(),
SizedBox(
height: 320,
child: client.render(), // Luqta contests section
),
const MyOtherContent(),
],
),
);
}
}
Example 3: Custom UI — full control
class CustomContestsPage extends StatelessWidget {
final LuqtaClient client;
const CustomContestsPage({super.key, required this.client});
@override
Widget build(BuildContext context) {
return ContestsScreen(
locale: 'en',
showSeeAllButton: true,
onFetchContests: ({required int page, required int limit}) async {
final res = await client.contests.getAll();
return res.data.items.map((e) => Contest.fromJson(e)).toList();
},
onContestTap: (contest) => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ContestDetailScreen(
contest: contest,
locale: 'en',
onBack: () => Navigator.pop(context),
onFetchContestDetails: (id) async {
final res = await client.getContestDetailsProgress(id);
return Contest.fromJson(res['data']);
},
onCompleteLevel: (levelId, type,
{textContent, qrCode, link, imageUrl, webhookPayload}) async {
final res = await client.levels.complete(levelId, data: {
if (textContent != null) 'textContent': textContent,
if (qrCode != null) 'qrCode': qrCode,
if (link != null) 'link': link,
});
return res['success'] == true;
},
onJoinContest: (id, code) async {
await client.contests.participate(id, accessCode: code);
final res = await client.getContestDetailsProgress(id);
return Contest.fromJson(res['data']);
},
),
),
),
);
}
}
Available Widgets
Screens
| Widget | Description |
|---|---|
ContestsScreen |
Full contest list (horizontal carousel or vertical) |
ContestDetailScreen |
Contest detail with levels, progress, prizes |
Reusable Widgets
| Widget | Description |
|---|---|
ContestCard |
Contest card for custom lists |
LevelItemView |
Level row with icon, status, points |
QuizWidget |
Interactive quiz with answer flow |
CongratulationDialog |
Level/contest completion celebration |
LuqtaToast |
Toast notifications (renders above bottom sheets) |
Level Completion Widgets
| Widget | Description |
|---|---|
TextLevelView |
Free-text answer input |
QRLevelView |
Camera QR code scanner |
LinkLevelView |
URL submission |
ImageLevelView |
Image capture / upload |
Support
- Bugs & Feature Requests: GitHub Issues
- Email: support@luqta.com
- Documentation: docs.luqta.com
License
MIT License — see LICENSE for details.
Libraries
- luqta_sdk
- Official Flutter SDK for Luqta API.