Variables & Types
Dart is a statically typed language — every variable has a type the compiler knows at build time. If you've used Java, TypeScript, or Kotlin, most of this will feel familiar.
Declaring variables
// var — Dart infers the type from what you assign
var name = 'Gaurav'; // Dart knows this is a String
var count = 42; // Dart knows this is an int
// Explicit type — same result, more readable
String name = 'Gaurav';
int count = 42;
double score = 9.5;
bool isLoading = false;
// final — can only be assigned once (like Java's final)
final uid = FirebaseAuth.instance.currentUser?.uid;
// uid can never be changed after this line
// const — compile-time constant, known before app runs
const maxPages = 100;
const appName = 'Quix';
DART
static const _kBasicTierPageLimit = 10; — a constant that can never change, defined inside a class, private (starts with _).
Collections
// List — ordered, like JavaScript array
List<String> tierNames = ['Spark', 'Flash', 'Pro', 'Research'];
tierNames[0]; // 'Spark'
tierNames.length; // 4
// Map — key-value pairs, like JavaScript object or Java HashMap
Map<String, dynamic> pack = {
'title': '🧠 Physics',
'questionCount': 20,
'isAiGenerated': true,
};
pack['title']; // '🧠 Physics'
// Set — unique values only, no duplicates
Set<int> selectedPages = {1, 2, 3};
selectedPages.add(2); // still {1, 2, 3} — duplicates ignored
DART
dynamic means "I don't know the type at compile time — trust me at runtime". You'll see Map<String, dynamic> everywhere in Firebase reads because Firebase returns JSON which can have mixed types.
String interpolation
final uid = 'abc123';
final path = 'users/$uid/packs'; // users/abc123/packs
// For expressions (not just variables), use ${}
final msg = '${tierNames[0]} plan selected'; // Spark plan selected
final pct = '${(0.75 * 100).round()}%'; // 75%
DART
Type casting
// Firebase returns everything as Object? — you must cast to use it
final snap = await ref.get();
final raw = snap.value as Map; // hard cast — crashes if wrong type
final pts = (snap.value as num?)?.toInt() ?? 0; // safe cast
// Reading from Map safely (common Firebase pattern)
final data = Map<String, dynamic>.from(snap.value as Map);
final points = (data['points'] as num?)?.toInt() ?? 0;
// ↑ cast to num ↑ null-safe ↑ to int ↑ default 0
DART
async / await / Future
Almost everything in Quix is asynchronous — Firebase reads, AI calls, file picks. Understanding this is the most important thing for debugging.
What is a Future?
A Future<T> is a promise that a value of type T will be available later. Like JavaScript's Promise.
// This function doesn't block — it returns immediately
// with a Future that completes later
Future<int> getPoints() async {
final snap = await FirebaseDatabase.instance
.ref('users/$uid/points')
.get();
return (snap.value as num?)?.toInt() ?? 0;
}
// Using it:
final pts = await getPoints(); // waits here until Firebase responds
DART
await. Without it, you get the Future object, not the value. The variable will be of type Future<int> instead of int and the compiler will warn you.
Parallel execution with Future.wait
// Sequential — takes 45s total (each waits for previous)
final r1 = await _tryGemini(prompt, count); // 15s
final r2 = await _tryClaudeHaiku(prompt, count); // 15s
final r3 = await _tryGroqLlama(prompt, count); // 15s
// Parallel — takes ~15s total (all run at the same time)
final results = await Future.wait([
_tryGemini(prompt, count),
_tryClaudeHaiku(prompt, count),
_tryGroqLlama(prompt, count),
]);
// results[0] = Gemini result
// results[1] = Claude result
// results[2] = Groq result
DART
unawaited — fire and forget
// With await — blocks until ambient starts (user waits)
await SoundService().startAmbient();
// With unawaited — starts ambient in background, moves on immediately
unawaited(SoundService().startAmbient());
// Why? Ambient music starting has nothing to do with showing the UI.
// Don't make the user wait for it.
DART
timeout — don't wait forever
// Without timeout — if Firebase is slow, UI hangs forever
final snap = await ref.get();
// With timeout — fails after 4 seconds, shows error instead of hanging
final snap = await ref.get().timeout(
const Duration(seconds: 4),
onTimeout: () => throw TimeoutException('Firebase too slow'),
);
// Pattern in Quix — timeout + catch + default value
try {
final snap = await ref.get().timeout(Duration(seconds: 3));
return (snap.value as num?)?.toInt() ?? 0;
} catch (_) {
return 0; // on any error, return 0
}
DART
Stream — continuous data flow
// Stream is like a Future that fires multiple times.
// Firebase RTDB uses streams for real-time updates.
// Listen to a stream
FirebaseDatabase.instance
.ref('leaderboard')
.onValue // stream of database events
.listen((event) {
// this runs every time leaderboard changes
final data = event.snapshot.value;
setState(() => _leaderboard = data);
});
// FCM push notifications also use a stream
FirebaseMessaging.onMessage.listen((msg) {
// this runs every time a push arrives while app is open
});
DART
Null Safety
Dart's null safety is the reason you see so many ? and ! symbols. It prevents null pointer crashes at compile time — the compiler forces you to handle nulls.
Nullable vs non-nullable
// Non-nullable — can NEVER be null, compiler enforces this
String name = 'Gaurav';
name = null; // ❌ compile error
// Nullable — add ? to allow null
String? name = null; // ✅ allowed
name = 'Gaurav'; // also allowed
DART
The ? operator — safe access
final user = FirebaseAuth.instance.currentUser; // User? (might be null)
// Without ?: crashes if user is null
final uid = user.uid; // ❌ compiler error — user might be null
// With ?. — returns null if user is null, uid if not
final uid = user?.uid; // ✅ String? — might be null
// Chaining — each ?. short-circuits on null
final snap = await ref.get();
final text = snap.value?.['content']?.[0]?.['text'] as String?;
// Reads nested JSON safely — returns null at first null instead of crashing
DART
The ?? operator — null coalescing
// ?? means "if null, use this default instead"
final pts = snap.value ?? 0; // 0 if snap.value is null
final name = user?.uid ?? 'Guest'; // 'Guest' if uid is null
// Real Quix code
final points = (data['points'] as num?)?.toInt() ?? 0;
// ↑ cast null-safely ↑ convert ↑ default
DART
The ! operator — force unwrap (dangerous)
// ! says "I guarantee this is not null — trust me"
// Crashes at runtime if you're wrong
final uid = FirebaseAuth.instance.currentUser!.uid;
// If currentUser is null → NullPointerException crash
// When is it safe? Only when you've JUST checked for null
if (user != null) {
final uid = user!.uid; // ✅ safe — we just checked
}
// Better — use the safe pattern instead:
final uid = user?.uid;
if (uid == null) return;
// uid is now guaranteed non-null by Dart's flow analysis
DART
Null check operator used on a null value. Find the ! that caused it and replace with a null check or ?? default.
Classes & OOP
Every service, screen, and widget in Quix is a class. Understanding how Dart classes work unlocks the entire codebase.
Basic class anatomy
class SoundService {
// ── Singleton pattern (one instance for whole app) ─────────────────────
static final SoundService _instance = SoundService._();
factory SoundService() => _instance;
SoundService._(); // private constructor — can't use new SoundService()
// ── Fields (instance variables) ─────────────────────────────────────────
bool _soundEnabled = true; // private (starts with _)
// ── Getter — reads a value (no parentheses when calling) ─────────────────
bool get soundEnabled => _soundEnabled;
// ── Setter — called when you assign: service.soundEnabled = false ─────────
set soundEnabled(bool v) {
_soundEnabled = v;
_persist('quix_sound_on', v);
}
// ── Method ─────────────────────────────────────────────────────────────────
Future<void> startAmbient() async {
if (!_soundEnabled) return; // guard clause
// ...
}
}
DART
Inheritance — extends
// StatefulWidget is Flutter's base class for widgets with state
class QuizPlayScreen extends StatefulWidget {
const QuizPlayScreen({super.key}); // passes key to parent
@override // annotation: I'm overriding a parent method
State<QuizPlayScreen> createState() => _QuizPlayScreenState();
}
class _QuizPlayScreenState extends State<QuizPlayScreen> {
@override
void initState() { // runs once when screen first appears
super.initState(); // always call parent first
_loadData();
}
@override
void dispose() { // runs when screen is destroyed
_controller.dispose(); // always clean up controllers
super.dispose(); // always call parent last
}
@override
Widget build(BuildContext context) { // runs every setState()
return Scaffold(child: Text('Quiz'));
}
}
DART
Enum — named set of values
// Enums prevent typos and make switch statements exhaustive
enum QuizMode { solo, multiplayer, study }
enum QuizTier { free, basic, premium }
// Usage
QuizMode _mode = QuizMode.solo;
switch (_mode) {
case QuizMode.solo:
// solo logic
case QuizMode.multiplayer:
// multiplayer logic
case QuizMode.study:
// study logic
}
// Compiler warns if you forget a case
DART
Common Dart Patterns
These patterns appear dozens of times in Quix. Recognising them speeds up reading by 10x.
Arrow functions — single expression
// Long form
String dayKey(DateTime d) {
return '${d.year}-${d.month.toString().padLeft(2, '0')}';
}
// Arrow shorthand — same thing, one line
String dayKey(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}';
DART
Spread operator and collection-if
// Collection-if — conditionally include widgets in a list
Column(children: [
Text('Always shown'),
if (_totalPdfPages > 1) ...[ // only if PDF has multiple pages
SizedBox(height: 16),
_buildPagePicker(), // these two only render conditionally
],
_buildGenerateButton(), // always shown
]);
// ... spread — inserts all items from a list into another list
final items = [1, 2];
final more = [...items, 3, 4]; // [1, 2, 3, 4]
DART
Closures and callbacks
// A function passed as a parameter — common in Flutter
GestureDetector(
onTap: () { // anonymous function (closure)
setState(() => _tab = _Tab.document);
},
child: Text('Document'),
);
// Arrow shorthand
GestureDetector(
onTap: () => setState(() => _tab = _Tab.document),
child: Text('Document'),
);
// Passing a named function
GestureDetector(
onTap: _pickFile, // no (), just the reference
child: Text('Pick file'),
);
DART
late — deferred initialisation
// late tells Dart: "this will be set before first use, trust me"
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, ...); // initialised here
}
// Crash if you forget to initialise before use:
// LateInitializationError: Field '_ctrl' has not been initialized
DART
List operations — map, where, fold
final questions = [q1, q2, q3, q4];
// .map() — transform each item
final maps = questions.map((q) => q.toMap()).toList();
// Each AiQuestion converted to Map for Firebase storage
// .where() — filter items
final correct = questions.where((q) => q.correctIndex == selected).toList();
// .fold() — reduce to single value
final maxQ = entries.fold<int>(0, (max, e) {
return e.quizzes > max ? e.quizzes : max;
});
// .any() — true if at least one matches
final hasGoogle = user.providerData
.any((p) => p.providerId == 'google.com');
DART
Widget Lifecycle
In Flutter, everything on screen is a Widget. Understanding the lifecycle tells you when to load data, when to clean up, and why your data sometimes appears blank.
StatelessWidget vs StatefulWidget
| StatelessWidget | StatefulWidget |
|---|---|
| No mutable state | Has data that changes |
| build() called once | build() called on every setState() |
| Faster | Slower but necessary |
| _MiniStat, _PrefToggle in Quix | AiQuizScreen, HomeScreen, etc. |
StatefulWidget lifecycle
class _ProgressDashboardState extends State<ProgressDashboardScreen> {
// 1️⃣ CONSTRUCTOR — runs first, before widget is on screen
// Use for: declaring variables
bool _loading = true;
Map<String, dynamic> _data = {};
// 2️⃣ initState — runs once, widget just mounted
// Use for: loading data, setting up controllers, subscriptions
@override
void initState() {
super.initState(); // MUST be first line
_barCtrl = AnimationController(vsync: this, ...);
_loadData(); // start async load
}
// 3️⃣ build — runs on first render AND every setState()
// Keep this fast — no heavy work here
@override
Widget build(BuildContext context) {
return _loading ? CircularProgressIndicator() : _buildContent();
}
// 4️⃣ dispose — runs when screen is popped/destroyed
// Use for: cancelling streams, disposing controllers
@override
void dispose() {
_barCtrl.dispose(); // MUST dispose every AnimationController
_subscription?.cancel(); // MUST cancel streams
super.dispose(); // MUST be last line
}
}
DART
setState() after dispose(). Happens when an async function completes after the user navigated away. Always check if (!mounted) return; after any await before calling setState().
State & setState
State is data that can change. When state changes, Flutter redraws the widget. setState() is how you tell Flutter "something changed, redraw now".
How setState works
class _AiQuizScreenState ... {
bool _isProcessing = false; // state variable
String _spinnerText = '';
void _startProcessing() {
setState(() { // ← schedules a rebuild
_isProcessing = true; // change state inside here
_spinnerText = 'Loading...';
});
// Flutter calls build() again
// build() sees _isProcessing = true → shows spinner
}
}
// In build():
return _isProcessing ? _buildSpinner() : _buildForm();
// Switches between spinner and form automatically when setState fires
DART
ValueNotifier — observable value
// ValueNotifier is a lightweight "observable" — listeners run when value changes
// Used in Quix for points display on home screen
// In PointsService (global):
static final ValueNotifier<int> pointsNotifier = ValueNotifier<int>(0);
// When points change (after quiz, after purchase):
pointsNotifier.value = newPoints; // ← triggers all listeners
// In HomeScreen (UI listens):
ValueListenableBuilder<int>(
valueListenable: PointsService.pointsNotifier,
builder: (context, points, _) {
// runs every time pointsNotifier.value changes
return Text('$points pts');
},
)
// No setState() needed — the notifier drives the UI rebuild automatically
DART
Singleton Pattern
Quix uses singletons for every service class. The singleton ensures there is exactly one instance shared across the entire app — so SoundService settings, Firebase connections, and billing state are never duplicated.
How Quix implements singletons
class SoundService {
// 1. Private static instance — created once, stored forever
static final SoundService _instance = SoundService._();
// 2. Factory constructor — returns the same instance every time
factory SoundService() => _instance;
// 3. Private real constructor — only runs once
SoundService._() {
_configurePlayers(); // setup runs once at startup
}
}
// Usage anywhere in the app:
SoundService().startAmbient(); // always the SAME SoundService object
SoundService().stopAmbient(); // same object, same _ambientPlayer
DART
Named singleton (.instance)
// Some services use .instance instead of factory constructor
class BillingService {
BillingService._();
static final BillingService instance = BillingService._();
}
// Usage:
BillingService.instance.purchase(kProductResearchProMonthly);
NotificationService.instance.scheduleDailyReminder(hour: 8, minute: 0);
GoogleAuthService.instance.linkGoogleAccount();
DART
_soundEnabled get set on the singleton, or did you accidentally create a new instance? If you see SoundService() vs SoundService.instance — they may be different patterns. In Quix, SoundService() uses factory so it's always the same object.
All singletons in Quix
| Class | Access pattern | Purpose |
|---|---|---|
SoundService | SoundService() | Audio playback |
BillingService | BillingService.instance | IAP purchases |
NotificationService | NotificationService.instance | Push notifications |
GoogleAuthService | GoogleAuthService.instance | Google Sign-In |
PremiumTierService | PremiumTierService.instance | AI tier checks |
DiagramService | DiagramService.instance | PDF extraction |
AdService | AdService() | AdMob ads |
TesterService | TesterService.instance | Dev UID whitelist |
Observer Pattern — ValueNotifier
The Observer pattern means: "tell me when something changes". Quix uses ValueNotifier for this. The home screen's live points display works entirely through this pattern without any explicit data fetching.
The pattern in Quix
// ── PUBLISHER (PointsService) ──────────────────────────────────────────
class PointsService {
// Global notifier — anyone in the app can listen to this
static final ValueNotifier<int> pointsNotifier = ValueNotifier(0);
static void updatePoints(int newPoints) {
pointsNotifier.value = newPoints; // all listeners instantly notified
}
}
// ── SUBSCRIBER (HomeScreen) ────────────────────────────────────────────
class _HomeScreenState ... {
@override
void initState() {
super.initState();
// Subscribe — this function runs every time points change
PointsService.pointsNotifier.addListener(_onPointsChanged);
}
void _onPointsChanged() {
setState(() {
_displayPoints = PointsService.pointsNotifier.value;
});
}
@override
void dispose() {
// MUST unsubscribe or you'll get setState after dispose crash
PointsService.pointsNotifier.removeListener(_onPointsChanged);
super.dispose();
}
}
DART
themeNotifier — global theme switching
// main.dart — global theme notifier
final themeNotifier = ValueNotifier<String>('system');
// MaterialApp wraps in ValueListenableBuilder
ValueListenableBuilder<String>(
valueListenable: themeNotifier,
builder: (_, mode, __) => MaterialApp(
theme: mode == 'dark' ? QuixTheme.theme : QuixTheme.lightTheme,
// ...
),
)
// ThemePicker changes one line, entire app re-themes instantly
themeNotifier.value = 'chrome';
DART
Service Layer Architecture
Quix separates business logic from UI using a service layer. Screens don't talk to Firebase directly — they ask services. This makes debugging easier: if points are wrong, check PointsService, not every screen.
Layer architecture
┌─────────────────────────────────────────────────────┐
│ SCREENS (UI layer) │
│ home_screen, quiz_play_screen, ai_quiz_screen... │
│ Only knows: show data, react to user taps │
└──────────────────────┬──────────────────────────────┘
│ calls services
┌──────────────────────▼──────────────────────────────┐
│ SERVICES (business logic layer) │
│ PointsService, AiService, SoundService... │
│ Knows: how to calculate points, call AI, play sound│
└──────────────────────┬──────────────────────────────┘
│ reads/writes
┌──────────────────────▼──────────────────────────────┐
│ DATA LAYER │
│ Firebase RTDB, Supabase, SharedPreferences, Files │
└─────────────────────────────────────────────────────┘
ARCHITECTURE
Example: how points flow through layers
// 1. SCREEN — user answers correctly
_applyScoreDelta(100); // local UI update (instant)
_commitPoints(delta: 100); // tells service to persist
// 2. SERVICE — PointsService.applyDelta()
await _userRef(uid).runTransaction((current) {
data['points'] = prevPoints + delta;
return Transaction.success(data);
});
pointsNotifier.value = updatedPoints; // notify home screen
await _updateBoard(_lbRef(uid), delta: delta); // update leaderboard
// 3. DATA — Firebase RTDB
// users/{uid}/points +100
// leaderboard/{uid}/score +100
DART
Firebase Architecture
Quix uses Firebase Realtime Database (RTDB) — a JSON tree stored in the cloud with real-time sync. Understanding the data structure is essential for debugging points, leaderboard, and daily progress issues.
RTDB data structure
// Firebase RTDB is a giant nested JSON object
{
"users": {
"{uid}": {
"points": 600, // current wallet balance
"aiTokens": 1,
"displayName": "Gaurav",
"dailyProgress": {
"2024-04-07": {
"quizzes": 3,
"correct": 12,
"score": 600
}
},
"packs": {
"{packId}": { "title": "Physics", ... }
}
}
},
"leaderboard": {
"{uid}": {
"score": 5250, // cumulative ALL TIME
"name": "Gaurav"
}
},
"rooms": { ... } // multiplayer rooms
}
JSON
Reading from Firebase
// One-time read (get)
final snap = await FirebaseDatabase.instance
.ref('users/$uid/points')
.get();
if (snap.exists && snap.value != null) {
final pts = (snap.value as num).toInt();
}
// Read entire user object
final snap = await FirebaseDatabase.instance
.ref('users/$uid')
.get();
final data = Map<String, dynamic>.from(snap.value as Map);
DART
Writing with transaction (safe concurrent update)
// runTransaction prevents race conditions when two devices update simultaneously
await ref.runTransaction((current) {
final data = current is Map
? Map<Object?, Object?>.from(current)
: <Object?, Object?>{}; // handle null (new user)
final prev = (data['points'] as num?)?.toInt() ?? 0;
data['points'] = prev + delta; // add to existing
return Transaction.success(data); // commit
// return Transaction.abort(); // abort if something's wrong
});
DART
AI Fallback Chain
Quix calls 6 AI APIs. The fallback chain ensures if one fails (rate limit, no key, timeout), the next one picks up. Understanding this helps debug "generation failed" errors.
How the chain works
// For Research tier: ALL 6 run simultaneously
final results = await Future.wait([
_tryGeminiPro(prompt, count), // Gemini 2.5 Flash
_tryClaudeHaiku(prompt, count), // Claude Haiku
_tryGroqLlama(prompt, count), // LLaMA 3.3 70B
_tryGroqGemma(prompt, count), // Gemma 2 9B
_tryMistral(prompt, count), // Mistral Small
_tryCohere(prompt, count), // Cohere Command R+
]);
// Each returns null if it fails — Future.wait still completes
// For free/basic tier: sequential fallback
for (final (label, fn) in [
('Gemini', _tryGemini),
('Groq', _tryGroq),
('Mistral', _tryMistral),
]) {
final result = await fn(prompt, count);
if (result != null) return result; // first success wins
}
DART
Each model method structure
Future<List<AiQuestion>?> _tryGeminiPro(String prompt, int count) async {
final key = AppConfigService.geminiApiKey;
if (key.isEmpty) return null; // 1. No API key → skip
try {
final res = await http.post(...).timeout(Duration(seconds: 45));
if (res.statusCode != 200) return null; // 2. API error → skip
final text = ... as String?;
return text != null ? _parseQuestions(text, count) : null;
// 3. Parse JSON → return questions, or null if parse fails
} catch (e) {
debugPrint('[PremiumTier] Gemini error: $e');
return null; // 4. Any exception → skip, don't crash
}
}
DART
[PremiumTier] or [AiService]. You'll see which models were tried and which returned null. Common causes: API key missing in Supabase vault, model rate limit (429 status), timeout (slow network), or JSON parse error (model returned non-JSON).
App Startup Flow
What happens in the first 3 seconds after the user taps the Quix icon. Understanding this flow explains most "blank screen" and "data not loading" bugs.
// main.dart — sequential startup
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // Flutter engine ready
await Firebase.initializeApp(); // Firebase SDK ready
GoogleAuthService.instance.init(); // set up Google listener
await AppConfigService.init(); // load API keys from Supabase
runApp(const QuixApp());
}
DART
// _AuthGateState._init() — runs after app is visible
Future<void> _init() async {
await SoundService().loadPrefs(); // load sound on/off pref
unawaited(SoundService().startAmbient()); // start music
unawaited(NotificationService.instance.restoreScheduledReminders());
try {
// Subscribe to FCM topic (run once — persists in Firebase)
await FirebaseMessaging.instance.subscribeToTopic('all_users');
} catch (_) {}
final user = await _signIn(); // anonymous sign-in
if (user != null) {
final deviceId = await PackStorageService.getAndroidId();
await TesterService.init(deviceId: deviceId); // dev whitelist
await PackStorageService.ensureUserRoot(user.uid); // create DB node
unawaited(_runIdentityInit(user.uid)); // set display name
PresenceService.start(user.uid); // online indicator
unawaited(PointsService.primeCache(user.uid)); // cache points
}
setState(() => _ready = true); // show HomeScreen
}
DART
AppConfigService loaded from Supabase → [Notif] FCM token: → [PackStorage] androidId= → [SoundService] prefs loaded → [Notif] All channels created. If any are missing, that feature is broken.
Quiz Play Flow
How a quiz session works end-to-end — from opening the pack to committing points to Firebase.
// QuizPlayScreen opens
initState() {
_bootstrapPoints(); // read current points from Firebase
_loadQuestions(); // decrypt/fetch questions from pack
}
// _bootstrapPoints
final points = await PointsService.getGlobalPoints(uid);
setState(() {
_score = points; // local display score = Firebase value
_startingGlobalPoints = points; // remember where we started
_sessionScore = 0; // session delta starts at 0
});
DART
// User answers a question
void _onAnswer(int index) {
_answered = true;
_selectedAnswer = index;
if (index == correct) {
_applyScoreDelta(100 * _streakMultiplier); // +100 to local score
SoundService().correct();
} else {
_applyScoreDelta(-20); // -20 penalty
SoundService().wrong();
}
}
// _applyScoreDelta — updates local state instantly (no Firebase wait)
void _applyScoreDelta(int delta) {
setState(() {
_score = max(0, _score + delta); // floor at 0
_sessionScore += delta; // track session total
});
}
DART
// Quiz ends — commit to Firebase
// _pendingCommitDelta = (_score - _startingGlobalPoints) - _committedDelta
await _commitPoints(delta: _pendingCommitDelta, countGame: true);
unawaited(_logDailyProgress()); // update dashboard data
DART
_score = user's total global balance displayed on screen. _sessionScore = delta earned this session only. Leaderboard gets the session delta, wallet gets the new total. This prevents the leaderboard score drifting away from the wallet — both get the same delta.
AI Generation Flow
What happens between "Generate Quiz" tap and seeing questions — and where it can go wrong.
// 1. Validate + gate
if (_exceedsLimit) { return; } // page limit check
final gateOk = await _unlockTierGate(); // show ads/payment if needed
if (!gateOk) return;
// 2. Document processing
final docResult = await DocumentService().process(_pickedFile!);
cleanedText = docResult.cleanedText; // extracted text
cleanedText += _preferencesSuffix; // append user prefs
// 3. Duplicate check
// SHA-256 hash of content → check if pdfHashes/{hash} exists in RTDB
// If yes → show "already generated" dialog
// 4. Cache check
final cached = await QuizCacheService.instance.load(hash, count, difficulty);
if (cached != null) { await _launch(cached); return; } // instant!
// 5. AI extraction
if (_selectedTierIdx == _kTierResearch) {
// 6 models parallel → majority vote merge
questions = await PremiumTierService.instance.extractMultiModel(...);
} else {
// Single DiagramService call (Gemini vision for PDFs)
questions = await DiagramService.instance.extractAndAttach(...);
}
// 6. Save pack to Firebase + local cache
await PackStorageService.savePack(pack);
await QuizCacheService.instance.save(hash, count, difficulty, questions);
DART
| Step fails | Symptom | Check in Logcat |
|---|---|---|
| API key missing | "Generation failed" instantly | AppConfigService: ✗ not set |
| Network timeout | Spinner for 45s then fail | TimeoutException |
| Model returns non-JSON | "Generation failed" | [PremiumTier] parse error |
| Document too small | "Document appears empty" | cleanedText.length < 50 |
| Cache hit | Instant generation (good!) | [AiQuiz] Cache HIT |
Points & Leaderboard
Why your header shows 600 but leaderboard shows 5250 — and how to keep them in sync.
// THREE separate numbers — all start from same delta but diverge over time
// 1. Wallet — users/{uid}/points
// Your current balance. Goes UP on correct answers, DOWN on wrong.
// Can go to 0 minimum. This is what the header shows.
// 2. Leaderboard — leaderboard/{uid}/score
// Cumulative total. Also gets deltas (same +/- as wallet).
// In theory synced with wallet, but can drift if:
// • You edited wallet directly in Firebase Console
// • An old app version only wrote to one path
// 3. Weekly leaderboard — leaderboard_weekly/{uid}/score
// Resets every week based on weekKey field
// Fix drift — run once in development console or a debug button:
static Future<void> syncLeaderboardToWallet(String uid) async {
final walletPts = await PointsService.getGlobalPoints(uid);
await FirebaseDatabase.instance
.ref('leaderboard/$uid/score').set(walletPts);
await FirebaseDatabase.instance
.ref('leaderboard_weekly/$uid/score').set(walletPts);
}
DART
Reading Flutter Errors
Flutter error messages are verbose but follow patterns. Here's how to decode the most common ones.
RenderFlex overflowed
══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞════
A RenderFlex overflowed by 12 pixels on the bottom.
The relevant widget: Column
Column:file:///progress_dashboard_screen.dart:424:20
→ Translation: A Column (vertical layout) is too tall for its container.
→ Fix options:
1. Wrap in SingleChildScrollView
2. Wrap overflow child in Expanded
3. Reduce sizes/padding inside the Column
4. Wrap in ClipRect to hide overflow visually
FLUTTER ERROR
setState after dispose
FlutterError: setState() called after dispose()
This error happens if you call setState() on a State object
for a widget that no longer appears in the widget tree.
→ Translation: async function completed after user navigated away.
→ Fix: always check mounted before setState after any await
WRONG:
final data = await Firebase.ref.get();
setState(() => _data = data); // ← user may have left screen
CORRECT:
final data = await Firebase.ref.get();
if (!mounted) return; // ← safety check
setState(() => _data = data);
FLUTTER ERROR
Undefined method / getter
The method 'instance' isn't defined for the type 'MonetizationService'
→ Translation: You're calling .instance on a class that doesn't have it.
→ Fix: check how the class is supposed to be accessed.
Look at the class definition for:
- static final instance = ... → use ClassName.instance
- factory ClassName() => ... → use ClassName() (no .instance)
- No singleton at all → use ClassName() or create instance
DART ERROR
Null check operator on null
Null check operator used on a null value
→ Translation: You used ! but the value was actually null.
→ Find the ! in the stack trace file:line and replace:
WRONG: final uid = FirebaseAuth.instance.currentUser!.uid;
CORRECT:
final uid = FirebaseAuth.instance.currentUser?.uid;
if (uid == null) return;
// use uid safely below
DART ERROR
Permission denied (Firebase)
[Identity] initIdentity error (non-fatal):
[firebase_database/unknown] Permission denied
→ Translation: Your RTDB security rules blocked a read or write.
→ Debug steps:
1. Firebase Console → RTDB → Rules → Simulator
2. Set Operation: Read or Write
3. Set Location: e.g. /users/{yourUid}/dailyProgress
4. Set Auth: authenticated with your UID
5. Click "Run" → see which rule is blocking
→ Common fix: add the path to RTDB rules under users/$uid with:
".read": "auth.uid == $uid"
".write": "auth.uid == $uid"
FIREBASE
Duplicate definition
The name '_MiniStat' is already defined.
ai_quiz_screen.dart(1621, 7): The first definition of this name.
→ Translation: A class or method is declared twice in the same file.
→ Fix: search the file for 'class _MiniStat' — delete the duplicate.
→ In VS Code: Ctrl+F → search class _MiniStat → find which one to delete.
DART ERROR
Quick Logcat filters for Quix
| Filter | Shows |
|---|---|
[SoundService] | Sound play/stop/error events |
[Notif] | Notification scheduling, FCM token, channel creation |
[GoogleAuth] | Sign-in result, migration, photo sync |
[PackStorage] | androidId, identity init, pack save/load |
[PremiumTier] | Which AI models succeeded, merge result |
[AiQuiz] | Cache hit/miss, question count |
[Billing] | Purchase events, entitlement grants |
[Points] | Commit failures, delta values |
Permission denied | Firebase rules blocking |
AppConfigService | API key load status (✓ or ✗) |