Luqta Flutter SDK
Official Flutter SDK for the Luqta platform — integrate contests, quizzes, rewards, and gamification into your Flutter app in minutes.
Version: 1.5.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
- 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.5.0
Then run:
flutter pub get
Import in your Dart files:
import 'package:luqta_sdk/luqta_sdk.dart';
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 carousel with horizontal swipe
- 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) - All navigation and animations
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'},
]);
// 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
final notifications = await client.notifications.getAll();
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 |
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
At least one identifier is required. Priority for hash lookup: uuid → username → email → phoneNumber.
// 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',
)
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.getAll()
client.notifications.markAsRead(id)
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 |
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');
}
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.