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
In Quix code you'll see
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 vs Object
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
Most common async bug
Forgetting 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
When you see a null crash in Flutter
Logcat will say 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

StatelessWidgetStatefulWidget
No mutable stateHas data that changes
build() called oncebuild() called on every setState()
FasterSlower but necessary
_MiniStat, _PrefToggle in QuixAiQuizScreen, 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
Most common lifecycle bug
Calling 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

Navigation

Flutter uses a stack of screens. Push adds a screen, pop removes it. Understanding navigation explains why data sometimes gets lost between screens.

Push and pop

// Push — go to new screen
Navigator.push(context, MaterialPageRoute(
  builder: (_) => QuizPlayScreen(pack: pack),
));

// Pop — go back
Navigator.pop(context);

// Pop with a return value
Navigator.pop(context, 'play');   // returns 'play' to caller

// Caller reads the return value
final action = await showDialog(
  context: context,
  builder: (_) => _AiPackSavedDialog(...),
);
// action is 'play', 'packs', or null (if dismissed)
if (action == 'play') { /* launch quiz */ }
DART

pushReplacement — replace current screen

// After quiz ends — don't let user press back to quiz screen
Navigator.pushReplacement(context, MaterialPageRoute(
  builder: (_) => const HomeScreen(),
));
// Stack before: Home → AiQuiz → QuizPlay
// Stack after:  Home → QuizPlay (AiQuiz is gone)
DART

Passing data between screens

// Constructor parameters — most common in Quix
class QuizPlayScreen extends StatefulWidget {
  final Map<String, dynamic> pack;   // data passed in
  final QuizMode quizMode;

  const QuizPlayScreen({
    required this.pack,           // required — must be provided
    required this.quizMode,
    this.isRapidFire = false,    // optional with default
  });
}

// When navigating:
Navigator.push(context, MaterialPageRoute(
  builder: (_) => QuizPlayScreen(
    pack: myPack,
    quizMode: QuizMode.solo,
    // isRapidFire not provided → defaults to false
  ),
));
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
Why singleton matters for debugging
If sound is playing even after toggle, check: did _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

ClassAccess patternPurpose
SoundServiceSoundService()Audio playback
BillingServiceBillingService.instanceIAP purchases
NotificationServiceNotificationService.instancePush notifications
GoogleAuthServiceGoogleAuthService.instanceGoogle Sign-In
PremiumTierServicePremiumTierService.instanceAI tier checks
DiagramServiceDiagramService.instancePDF extraction
AdServiceAdService()AdMob ads
TesterServiceTesterService.instanceDev 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

User answers correctly
PointsService.applyDelta()
pointsNotifier.value = newPts
HomeScreen rebuilds
Shows new score
// ── 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
Why transactions instead of set()
If two quiz games finish at the same time and both read points=100, add 50, then write 150 — one write overwrites the other and you lose 50 points. A transaction reads AND writes atomically — Firebase retries if another write happened between your read and write.

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
Debugging "generation failed"
In Logcat search for [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
main()
Firebase init
runApp → QuixApp
_AuthGate builds
_init() runs
// _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
Logcat health check on startup
You should see these in order: 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
Why two score variables?
_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 failsSymptomCheck in Logcat
API key missing"Generation failed" instantlyAppConfigService: ✗ not set
Network timeoutSpinner for 45s then failTimeoutException
Model returns non-JSON"Generation failed"[PremiumTier] parse error
Document too small"Document appears empty"cleanedText.length < 50
Cache hitInstant 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

FilterShows
[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 deniedFirebase rules blocking
AppConfigServiceAPI key load status (✓ or ✗)