DEV Community

Cover image for Flutter Interview Questions Part 12: Dart Tricks, Architecture Dilemmas, Debugging & Predict the Output
Anurag Dubey
Anurag Dubey

Posted on

Flutter Interview Questions Part 12: Dart Tricks, Architecture Dilemmas, Debugging & Predict the Output

Welcome to Part 12 of the Flutter Interview Questions 2025 series! This is where interviews get truly intense. We are covering four sections that test the depth of your Dart and Flutter knowledge: Dart language gotchas (List reference traps, == vs identical(), late crashes), architecture dilemmas (BLoC vs Riverpod for a chat app, offline-first sync strategies), debugging scenarios (release-mode-only crashes, setState but UI stays stale), and predict-the-output code puzzles that expose your mental model of the event loop, widget lifecycle, and async timing. This is part 12 of our comprehensive 14-part series. Bookmark it and come back whenever you need a refresher before your next interview.

What's in this part?

  • Section 1: Dart Language Tricky Questions (18 Qs) — List reference traps, == vs identical(), final vs const Lists, late crashes, dynamic vs Object vs var, factory constructors, extension conflicts, sealed classes, mixin class, and more
  • Section 2: Architecture & Design Dilemmas (10 Qs) — BLoC vs Riverpod for chat, offline-first sync, token refresh interceptors, shared state across screens, deep linking with back stacks, team project structure, API versioning, Method Channel vs FFI, federated plugins, app size optimization
  • Section 3: Debugging & "What Went Wrong?" Scenarios (16 Qs) — Release-only crashes, setState but no UI update, hot reload stale UI, RenderFlex overflow, disposed widget errors, first-launch slowness, blurry images, deactivated widget ancestors, iOS-only HTTP failures, keyboard overflow, unbounded viewport, Firebase background notifications, emulator-vs-device crashes, BLoC double-fire, context.read vs context.watch, scroll performance
  • Section 4: Predict the Output (12 Qs) — FutureBuilder rebuilding, setState with async gaps, Key behavior with reordering, broadcast vs single-subscription streams, Provider notification timing, widget lifecycle ordering, null safety edge cases, isolate message passing, AnimationController states, BuildContext availability, event loop ordering, try-catch with Futures

SECTION 1: DART LANGUAGE TRICKY QUESTIONS


Q1: What's the output? List a = [1,2,3]; List b = a; b.add(4); print(a);

What the interviewer is REALLY testing:
Do you understand reference types vs value types in Dart? Do you know that assigning a List to another variable does NOT create a copy?

Answer:

Output: [1, 2, 3, 4]

In Dart, Lists (and all objects) are reference types. When you write List b = a, you are NOT copying the list. You are copying the reference — both a and b now point to the exact same list object in memory. Any mutation through b is visible through a because they are the same object.

List a = [1, 2, 3];
List b = a;           // b points to the SAME list object as a
b.add(4);
print(a);             // [1, 2, 3, 4]
print(identical(a, b)); // true — same object in memory
Enter fullscreen mode Exit fullscreen mode

How to actually copy a list:

// Shallow copy approaches:
List b = List.from(a);
List b = [...a];
List b = a.toList();

// Deep copy (for nested objects):
List<List<int>> a = [[1,2], [3,4]];
List<List<int>> b = a.map((inner) => List<int>.from(inner)).toList();
Enter fullscreen mode Exit fullscreen mode

Follow-up trap: Even List.from is a shallow copy. If the list contains objects, you still share references to those inner objects.


Q2: What's the difference between == and identical() in Dart?

What the interviewer is REALLY testing:
Do you understand structural equality vs referential identity? Do you know which one == uses by default and when the framework overrides it?

Answer:

Aspect == identical()
What it checks Equality (can be overridden) Identity — same object in memory
Can be overridden Yes, via operator == No, it is a top-level function
Default behavior Identity check (same as identical) Always identity check
class Point {
  final int x, y;
  Point(this.x, this.y);
}

var p1 = Point(1, 2);
var p2 = Point(1, 2);

print(p1 == p2);         // false — default == checks identity
print(identical(p1, p2)); // false — different objects
Enter fullscreen mode Exit fullscreen mode
class Point {
  final int x, y;
  Point(this.x, this.y);

  @override
  bool operator ==(Object other) =>
      other is Point && other.x == x && other.y == y;

  @override
  int get hashCode => Object.hash(x, y);
}

var p1 = Point(1, 2);
var p2 = Point(1, 2);

print(p1 == p2);          // true — overridden == checks fields
print(identical(p1, p2));  // false — still different objects in memory
Enter fullscreen mode Exit fullscreen mode

Key Dart special cases:

  • Integers: identical(1, 1) is true (small integers are canonicalized).
  • Strings: identical("hello", "hello") is true (compile-time string literals are interned).
  • const objects: identical(const Point(1,2), const Point(1,2)) is true (const canonicalization).

Q3: Why does "hello" == "hello" return true but MyClass() == MyClass() returns false?

What the interviewer is REALLY testing:
Do you understand that String overrides == to compare contents, while custom classes use the default Object.== which checks identity?

Answer:

Dart's String class overrides operator == to compare the actual character sequences. So two different String objects with the same content are considered equal.

Your custom class does not override operator == by default. It inherits Object.operator ==, which checks identity — are these the exact same object in memory? Two fresh instances are never identical.

print("hello" == "hello");       // true  — String.== compares contents
print(MyClass() == MyClass());   // false — Object.== compares identity

// What String.== effectively does:
// bool operator ==(Object other) => other is String && this.compareTo(other) == 0;
Enter fullscreen mode Exit fullscreen mode

Bonus insight: Dart string literals are also interned at compile time, so identical("hello", "hello") is also true. But that is a separate mechanism from == — it is the VM reusing the same object, not an equality override.


Q4: What happens if you override == but not hashCode?

What the interviewer is REALLY testing:
Do you understand the contract between == and hashCode? Do you know the real-world consequences in Sets and Maps?

Answer:

The contract: If a == b is true, then a.hashCode must equal b.hashCode. Violating this breaks every hash-based collection.

class User {
  final String email;
  User(this.email);

  @override
  bool operator ==(Object other) =>
      other is User && other.email == email;

  // FORGOT to override hashCode!
}

void main() {
  var set = <User>{};
  set.add(User("a@b.com"));
  set.add(User("a@b.com"));

  print(set.length); // 2!!! — should be 1 if hashCode was correct
  // The set uses hashCode first to find the bucket.
  // Different hashCodes → different buckets → never even calls ==.

  var map = <User, String>{};
  map[User("a@b.com")] = "Alice";
  print(map[User("a@b.com")]); // null!!! — can't find the key
}
Enter fullscreen mode Exit fullscreen mode

The mechanism:

  1. HashSet/HashMap call hashCode first to locate a bucket.
  2. Only if two objects land in the same bucket does it call ==.
  3. Default hashCode is based on identity (memory address), so two "equal" objects get different hash codes → different buckets → == is never called.

The Dart analyzer (linter) will warn you: hash_and_equals lint rule catches this.


Q5: Can you modify a final List? What about a const List? Why?

What the interviewer is REALLY testing:
Do you understand the difference between a final reference and an immutable object? This is one of the most commonly confused concepts.

Answer:

// final — the REFERENCE cannot be reassigned, but the OBJECT can be mutated.
final List<int> a = [1, 2, 3];
a.add(4);        // WORKS — mutating the list is fine
// a = [5, 6];   // COMPILE ERROR — can't reassign the variable

// const — the OBJECT itself is deeply immutable.
const List<int> b = [1, 2, 3];
// b.add(4);     // RUNTIME ERROR — Unsupported operation: Cannot add to an unmodifiable list
// b = [5, 6];   // COMPILE ERROR — also can't reassign

// This is also immutable:
final List<int> c = const [1, 2, 3];
// c.add(4);     // RUNTIME ERROR — the list object is const even though the variable is final
Enter fullscreen mode Exit fullscreen mode

The mental model:

  • final = "This variable name is permanently bound to this one object." The object itself has no restrictions.
  • const = "This object was created at compile time and is permanently frozen." Cannot be mutated. Period.

Trap question follow-up: What about final List<int> x = [];? This is a mutable growable list with a final reference. You can add, remove, clear — just can't do x = anotherList.


Q6: What's the output? int? x; print(x ?? 0); x ??= 5; print(x);

What the interviewer is REALLY testing:
Do you understand null-aware operators, and the subtle difference between ?? (evaluate) and ??= (assign)?

Answer:

Output:

0
5
Enter fullscreen mode Exit fullscreen mode

Step-by-step:

int? x;           // x is null (default for nullable types)
print(x ?? 0);    // x is null, so ?? returns 0. x itself is STILL null.
x ??= 5;          // x is null, so ??= assigns 5 to x. Now x == 5.
print(x);         // 5
Enter fullscreen mode Exit fullscreen mode

Key distinction:

  • x ?? 0 — evaluates to 0 but does not change x. It is an expression, not an assignment.
  • x ??= 5 — assigns 5 to x only if x is currently null. It is an assignment operator.

Follow-up trap:

int? x = 3;
print(x ?? 0);    // 3 — x is not null, so ?? returns x
x ??= 5;          // x is not null, so ??= does NOTHING
print(x);         // 3
Enter fullscreen mode Exit fullscreen mode

Q7: What happens with late String name; if you access it before assigning?

What the interviewer is REALLY testing:
Do you understand late — that it moves the null-safety check from compile time to runtime? Do you know the exact error?

Answer:

You get a LateInitializationError at runtime.

class User {
  late String name;
}

void main() {
  var u = User();
  print(u.name); // LateInitializationError: Field 'name' has not been initialized.
}
Enter fullscreen mode Exit fullscreen mode

Why late exists:

  • You are telling the compiler: "Trust me, this will be assigned before it is read."
  • The compiler removes the nullable check and treats name as non-nullable String.
  • But at runtime, Dart inserts a hidden sentinel value. On access, it checks if the sentinel is still there and throws if so.

Critical detail — late fields are lazily initialized if given an initializer:

late String name = expensiveComputation(); // NOT called until first read
Enter fullscreen mode Exit fullscreen mode

This is different from:

String name = expensiveComputation(); // Called immediately at construction
Enter fullscreen mode Exit fullscreen mode

Q8: Explain — why does this code compile but crash at runtime?

class Service {
  late final String token;

  void init() {
    token = fetchToken();
  }

  String get authHeader => 'Bearer $token';
}

void main() {
  var s = Service();
  print(s.authHeader); // CRASH
}
Enter fullscreen mode Exit fullscreen mode

What the interviewer is REALLY testing:
Can you identify temporal coupling bugs? Do you understand that late shifts null safety from compile time to runtime — effectively re-introducing the null reference problem?

Answer:

The code compiles because:

  1. token is declared late final String — non-nullable, so the compiler trusts it will be assigned.
  2. authHeader uses token — the compiler sees a non-nullable String, no warnings.
  3. init() assigns token — everything looks structurally correct.

It crashes because:

  1. main() creates Service but never calls init().
  2. authHeader accesses token before init() runs.
  3. Runtime throws LateInitializationError.

The lesson: late is a promise to the compiler. If you break the promise, you get a runtime crash. This is the Dart equivalent of a null pointer exception — late does not eliminate null safety problems, it just moves them.

How to fix:

// Option 1: Require token in constructor
class Service {
  final String token;
  Service(this.token);
  String get authHeader => 'Bearer $token';
}

// Option 2: Make it nullable and handle it
class Service {
  String? token;
  void init() { token = fetchToken(); }
  String get authHeader => 'Bearer ${token ?? "NO_TOKEN"}';
}

// Option 3: Use late with an initializer (if fetchToken is synchronous)
class Service {
  late final String token = fetchToken(); // lazy, called on first access
  String get authHeader => 'Bearer $token';
}
Enter fullscreen mode Exit fullscreen mode

Q9: What's the difference between dynamic, Object, and var?

What the interviewer is REALLY testing:
Do you understand Dart's type system at a fundamental level? This is the question that separates people who read the docs from people who understand the language.

Answer:

Feature dynamic Object / Object? var
Type checking None — disables static analysis Full — only Object members available Full — inferred type
Can call any method Yes (fails at runtime if wrong) No (compile error) No (compile error)
Type known at Runtime only Compile time (Object) Compile time (inferred)
Is it a type? Yes (special type) Yes (root of class hierarchy) No — it is a keyword for inference
dynamic a = "hello";
a.foo();            // Compiles fine. Crashes at runtime — NoSuchMethodError.
a.length;           // Compiles fine. Works at runtime (String has length).
a = 42;             // Fine — dynamic accepts anything.

Object b = "hello";
// b.length;        // COMPILE ERROR — Object has no .length
(b as String).length; // Works — explicit cast
b = 42;             // Fine — int is an Object.

var c = "hello";    // c is inferred as String
c.length;           // Works — compiler knows c is String
// c = 42;          // COMPILE ERROR — int is not String
Enter fullscreen mode Exit fullscreen mode

The critical insight:

  • dynamic turns off the type system. It is an escape hatch. Your code compiles but can crash at runtime.
  • Object keeps the type system on. You cannot call methods that aren't on Object without casting.
  • var is just shorthand — the compiler infers the real type and enforces it fully.

When to use each:

  • dynamic: Almost never. JSON decoding, interop, or when you truly do not know the type.
  • Object: When you need to hold "any object" but still want type safety.
  • var: Default choice for local variables when the type is obvious from the right side.

Q10: Can a factory constructor return null? Can a generative constructor?

What the interviewer is REALLY testing:
Do you understand the fundamental difference between factory and generative constructors? Do you know what a generative constructor is obligated to return?

Answer:

Generative constructor: Cannot return null. It always creates a new instance of the class (or throws). You cannot even write a return statement in a generative constructor body.

Factory constructor: It depends on the return type.

class Connection {
  // Generative — ALWAYS returns a new Connection. No return statement allowed.
  Connection();

  // Factory — returns Connection. Cannot return null because return type is non-nullable.
  factory Connection.create() {
    return Connection();
  }

  // Factory — CAN return null if the return type is nullable.
  // BUT: Dart does not allow a constructor to have a nullable return type.
  // So the answer is: factory constructors CANNOT return null either.
}
Enter fullscreen mode Exit fullscreen mode

The real answer is: neither can return null. Dart constructors always have a non-nullable return type matching the class. There is no syntax for factory MyClass?.

However, a factory constructor CAN:

  • Return an existing cached instance (singleton pattern).
  • Return a subtype (polymorphic construction).
  • Throw an exception instead of returning.
class Logger {
  static final Logger _instance = Logger._internal();

  // Returns the SAME instance every time — not null, but not new either.
  factory Logger() => _instance;

  Logger._internal();
}

// Subtypes:
abstract class Shape {
  factory Shape(String type) {
    if (type == 'circle') return Circle();
    if (type == 'square') return Square();
    throw ArgumentError('Unknown shape: $type');
  }
}
class Circle implements Shape {}
class Square implements Shape {}
Enter fullscreen mode Exit fullscreen mode

Q11: What happens if extension methods conflict?

What the interviewer is REALLY testing:
Do you know how Dart resolves ambiguity with extensions? This reveals whether you have actually used extensions in a non-trivial codebase.

Answer:

If two extensions define the same method on the same type and both are imported, you get a compile-time error — Dart will not silently pick one.

// file: string_ext_a.dart
extension StringExtA on String {
  String get shout => toUpperCase() + '!!!';
}

// file: string_ext_b.dart
extension StringExtB on String {
  String get shout => toUpperCase() + '???';
}

// file: main.dart
import 'string_ext_a.dart';
import 'string_ext_b.dart';

void main() {
  print("hello".shout); // COMPILE ERROR: ambiguous
}
Enter fullscreen mode Exit fullscreen mode

Resolution strategies:

// Strategy 1: Hide one
import 'string_ext_a.dart';
import 'string_ext_b.dart' hide StringExtB;

// Strategy 2: Use explicit extension application
import 'string_ext_a.dart';
import 'string_ext_b.dart';

void main() {
  print(StringExtA("hello").shout); // Explicit — no ambiguity
  print(StringExtB("hello").shout);
}

// Strategy 3: Use show to be selective
import 'string_ext_a.dart' show StringExtA;
Enter fullscreen mode Exit fullscreen mode

Priority rules when there is no ambiguity:

  1. Instance methods always win over extension methods.
  2. A more specific type extension wins: extension on int beats extension on num for an int.
  3. If specificity is equal, you must disambiguate manually.

Q12: How do sealed classes work and why are they useful with pattern matching?

What the interviewer is REALLY testing:
Do you understand Dart 3's sealed classes, exhaustiveness checking, and algebraic data types? This is the modern Dart question.

Answer:

A sealed class:

  1. Cannot be instantiated directly (abstract by nature).
  2. Can only be extended or implemented in the same library (same file or same library declaration).
  3. The compiler knows all possible subtypes at compile time → enables exhaustiveness checking in switch.
// All subtypes must be in the same file/library.
sealed class AuthState {}
class Authenticated extends AuthState {
  final String username;
  Authenticated(this.username);
}
class Unauthenticated extends AuthState {}
class Loading extends AuthState {}

// Exhaustive switch — compiler GUARANTEES you handle all cases.
String describe(AuthState state) {
  return switch (state) {
    Authenticated(username: var name) => 'Welcome, $name',
    Unauthenticated()                => 'Please log in',
    Loading()                        => 'Loading...',
    // No default needed! Compiler knows this is exhaustive.
    // If you add a new subtype, every switch breaks at compile time.
  };
}
Enter fullscreen mode Exit fullscreen mode

Why this is powerful:

  • If a developer adds class Error extends AuthState {}, every switch on AuthState in the codebase will produce a compile error until it handles Error.
  • This is impossible with regular abstract classes — the compiler cannot know all subtypes, so switch requires a default or _ wildcard.
  • This is the Dart equivalent of Kotlin's sealed class, Rust's enum, or Swift's enum with associated values.

Sealed vs abstract vs final vs interface:

Modifier Extend outside lib? Implement outside lib? Instantiate? Exhaustive switch?
sealed No No No Yes
abstract Yes Yes No No
final No No Yes No
interface No Yes Yes No

Q13: What's the difference between mixin and mixin class in Dart 3?

What the interviewer is REALLY testing:
Do you understand the Dart 3 class modifiers? Can you explain why mixin class exists?

Answer:

Before Dart 3, a regular class could be used as a mixin with with. Dart 3 restricted this — now only declarations marked mixin can be used in a with clause.

mixin class is the bridge — it can be used both as a regular class (extended, instantiated) and as a mixin (used with with).

// Pure mixin — cannot be instantiated or extended normally.
mixin Logging {
  void log(String msg) => print('[LOG] $msg');
}

// class MyLogger extends Logging {} // ERROR — can't extend a mixin
// var l = Logging();                // ERROR — can't instantiate a mixin
class MyService with Logging {}     // OK — used as mixin

// Mixin class — can be BOTH extended AND mixed in.
mixin class Validator {
  bool isValid(String input) => input.isNotEmpty;
}

class MyValidator extends Validator {}  // OK — used as class
class MyForm with Validator {}          // OK — used as mixin
var v = Validator();                    // OK — instantiated
Enter fullscreen mode Exit fullscreen mode

Restrictions on mixin (and mixin class):

  • Cannot have a mixin that extends anything other than Object.
  • A mixin class must have only a zero-argument constructor (no required params) to be mixable.

When to use which:

  • mixin — You only want to provide reusable behavior to be mixed in. No direct instantiation needed.
  • mixin class — You want a class that can serve double duty. Common for utility classes that provide behavior but also make sense standalone.
  • class — Normal usage. Cannot be used with with in Dart 3.

Q14: Can you have a constant Map with computed values? Why or why not?

What the interviewer is REALLY testing:
Do you understand compile-time constants in Dart? Do you know what the compiler can and cannot evaluate?

Answer:

No. const requires that every value (and key) be a compile-time constant. Computed values from function calls, runtime variables, or anything the compiler cannot evaluate at compile time cannot be const.

// VALID — all values are compile-time constants
const map1 = {
  'key1': 'value1',
  'key2': 42,
  'key3': true,
  'key4': 3 + 4,           // Constant expression — compiler evaluates to 7
  'key5': 'hello' + ' world', // Constant string concatenation — OK
};

// INVALID — function calls are not compile-time constants
const map2 = {
  'now': DateTime.now(),    // ERROR — DateTime.now() is runtime
  'random': Random().nextInt(10), // ERROR
};

// INVALID — non-const variables
final x = 42;
const map3 = {'key': x};   // ERROR — x is final, not const

// VALID — const variables work
const x = 42;
const map3 = {'key': x};   // OK
Enter fullscreen mode Exit fullscreen mode

What counts as a compile-time constant:

  • Numeric, string, bool, null literals
  • const constructors with const arguments
  • Arithmetic on constants (3 + 4)
  • String interpolation with constants ('${constVar}')
  • Ternary expressions on constants (constBool ? 1 : 2)
  • identical() on constants

What does NOT count:

  • Any function call (except const constructors and identical)
  • Accessing final variables (they are runtime)
  • DateTime.now(), Random(), any I/O

Q15: What's the difference between these two?

Future<void> foo() async { return; }
Future<void> bar() { return Future.value(); }
Enter fullscreen mode Exit fullscreen mode

What the interviewer is REALLY testing:
Do you understand the async machinery? Do you know that async implicitly wraps the return in a Future and schedules it as a microtask?

Answer:

Both return a Future<void>, but the timing differs.

Future<void> foo() async {
  return; // Body runs synchronously up to first await (there is none).
  // But the return value is STILL wrapped in a Future and delivered asynchronously.
}

Future<void> bar() {
  return Future.value(); // Returns an already-completed Future, also delivered asynchronously.
}
Enter fullscreen mode Exit fullscreen mode

The critical difference — error handling:

Future<void> foo() async {
  throw Exception('boom');
  // The exception is CAUGHT by the async machinery
  // and delivered as a Future error. The caller gets a failed Future.
}

Future<void> bar() {
  throw Exception('boom');
  // The exception is thrown SYNCHRONOUSLY.
  // If the caller doesn't have try-catch, it is an unhandled synchronous exception.
  // It does NOT become a Future error.
}
Enter fullscreen mode Exit fullscreen mode
void main() {
  // This is SAFE:
  foo().catchError((e) => print(e)); // Works — error is in the Future

  // This CRASHES before catchError can help:
  bar().catchError((e) => print(e)); // bar() throws synchronously, catchError is never reached
}
Enter fullscreen mode Exit fullscreen mode

Key insight: async functions guarantee that all exceptions (even synchronous ones) are routed into the returned Future. Non-async functions that return Futures do not have this guarantee — synchronous code before the return can throw directly.

Timing difference:

void main() {
  print('1');
  foo().then((_) => print('foo done'));
  print('2');
  bar().then((_) => print('bar done'));
  print('3');
}
// Output:
// 1
// 2
// 3
// foo done (or bar done — both are microtasks, order is deterministic but subtle)
// bar done
Enter fullscreen mode Exit fullscreen mode

Both complete asynchronously because Future.value() and the async return both schedule on the microtask queue.


Q16: What's the output of this?

void main() {
  String? name;
  name ??= 'Alice';
  name ??= 'Bob';
  print(name);
}
Enter fullscreen mode Exit fullscreen mode

What the interviewer is REALLY testing:
Do you understand that ??= is a no-op when the variable is already non-null?

Answer:

Output: Alice

String? name;       // null
name ??= 'Alice';   // name is null → assign 'Alice'. Now name == 'Alice'.
name ??= 'Bob';     // name is 'Alice' (not null) → do nothing.
print(name);        // Alice
Enter fullscreen mode Exit fullscreen mode

Q17: What's the output?

void main() {
  var x = [1, 2, 3];
  var y = [1, 2, 3];
  print(x == y);
  print(identical(x, y));

  const a = [1, 2, 3];
  const b = [1, 2, 3];
  print(a == b);
  print(identical(a, b));
}
Enter fullscreen mode Exit fullscreen mode

What the interviewer is REALLY testing:
Do you understand that List does NOT override == (so it uses identity), and that const collections are canonicalized (same content → same object)?

Answer:

Output:

false
false
true
true
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • x == yfalse because List does NOT override ==. Default Object.== checks identity. x and y are different objects.
  • identical(x, y)false — two separate list objects.
  • a == btrue because both are const. Dart canonicalizes const objects — a and b are the same object in memory. identical(a, b) is true, so == (identity check) is also true.
  • identical(a, b)true — const canonicalization.

This means: const does not make == smarter. It makes identical return true, and since default == checks identity, it becomes true as a side effect.


Q18: Explain the difference between is and as in Dart.

What the interviewer is REALLY testing:
Do you understand type testing vs type casting, and when each can fail?

Answer:

  • istests the type. Returns bool. Never throws.
  • ascasts the type. Returns the value as the target type. Throws TypeError if the cast is invalid.
Object value = 'hello';

// is — safe check
if (value is String) {
  // Inside this block, Dart PROMOTES value to String automatically.
  print(value.length); // No cast needed — smart cast / type promotion
}

// as — explicit cast (dangerous if wrong)
String s = value as String; // Works here
// int n = value as int;    // RUNTIME ERROR: TypeError

// Combining them for safety:
String? safe = value is String ? value : null; // Safe alternative to as
Enter fullscreen mode Exit fullscreen mode

Key insight — type promotion:
After an is check, Dart promotes the variable within that scope. You do not need as at all:

void process(Object obj) {
  if (obj is! String) return; // early return
  // From here on, obj is promoted to String
  print(obj.length);         // No cast needed
  print(obj.toUpperCase());  // No cast needed
}
Enter fullscreen mode Exit fullscreen mode

SECTION 2: ARCHITECTURE & DESIGN DILEMMAS


Q1: You're building a chat app — BLoC or Riverpod? Justify your choice with trade-offs.

What the interviewer is REALLY testing:
Can you think in trade-offs rather than absolutes? Do you have production experience with both? Can you match architecture to requirements?

Answer:

I would choose Riverpod for a chat app, but here is the full reasoning:

Why Riverpod fits chat better:

Concern Riverpod BLoC
Multiple streams (messages, typing, presence, read receipts) Natural — each is a separate provider, auto-disposed Need multiple BLoCs or complex multi-stream BLoC
Reactive composition Providers can watch other providers. Presence + messages → UI state Manual. Must inject BLoCs into BLoCs or use BlocListener chains
Disposal autoDispose per chat room — leave the screen, resources are freed Manual. Must close streams in dispose, or risk leaks
Testing Override any provider in the test tree — no DI framework needed Mockable, but need to set up stream expectations
Real-time WebSocket StreamProvider.autoDispose.family(chatRoomId) — one line Need StreamSubscription in BLoC, manual lifecycle

When I would pick BLoC instead:

  • The team already knows BLoC deeply and has established patterns.
  • The app is enterprise-scale with strict event-driven audit logging (BLoC's event system gives you a traceable log of every state change).
  • You need bloc_concurrency for throttling/debouncing events (e.g., typing indicators).
  • The company has standardized on BLoC.

Riverpod chat architecture sketch:

// Per-room message stream
final chatMessagesProvider = StreamProvider.autoDispose.family<List<Message>, String>(
  (ref, roomId) {
    final socket = ref.watch(webSocketProvider);
    return socket.messagesStream(roomId);
  },
);

// Typing indicator
final typingUsersProvider = StreamProvider.autoDispose.family<List<String>, String>(
  (ref, roomId) => ref.watch(webSocketProvider).typingStream(roomId),
);

// Send message action
final sendMessageProvider = Provider.autoDispose.family<void Function(String), String>(
  (ref, roomId) {
    final socket = ref.watch(webSocketProvider);
    return (text) => socket.send(roomId, text);
  },
);
Enter fullscreen mode Exit fullscreen mode

Key point: There is no universally correct answer. The interviewer wants to see you reason about trade-offs, not declare a winner.


Q2: Your app needs to work fully offline and sync when online — design the architecture.

What the interviewer is REALLY testing:
Do you understand offline-first architecture, conflict resolution, and the complexity of syncing?

Answer:

Architecture: Local-first with sync queue.

┌─────────────────────────────────────────────┐
│                    UI Layer                  │
│           (reads from local DB only)         │
└──────────────┬──────────────────────────────┘
               │ watches
┌──────────────▼──────────────────────────────┐
│              Local Database                  │
│         (Drift/Isar/Hive + SQLite)          │
│    Source of truth for the UI. Always.       │
└──────────────┬──────────────────────────────┘
               │
┌──────────────▼──────────────────────────────┐
│            Sync Engine                       │
│  ┌─────────────────────────────────────┐     │
│  │  Operation Queue (pending changes)  │     │
│  │  - create, update, delete ops       │     │
│  │  - each op has a timestamp + UUID   │     │
│  └──────────────┬──────────────────────┘     │
│                 │ when online                 │
│  ┌──────────────▼──────────────────────┐     │
│  │  Conflict Resolver                  │     │
│  │  - Last-write-wins / merge / ask    │     │
│  └──────────────┬──────────────────────┘     │
└─────────────────┼───────────────────────────┘
                  │
┌─────────────────▼───────────────────────────┐
│             Remote API / Firebase            │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key components:

// 1. Every model has sync metadata
class Todo {
  final String id;          // UUID generated on client
  final String title;
  final bool completed;
  final DateTime updatedAt; // For conflict resolution
  final SyncStatus syncStatus; // pending, synced, conflict
}

enum SyncStatus { synced, pendingUpload, pendingDelete, conflict }

// 2. Repository always writes locally first
class TodoRepository {
  final LocalDatabase _db;
  final SyncQueue _syncQueue;

  Future<void> addTodo(Todo todo) async {
    await _db.insertTodo(todo.copyWith(syncStatus: SyncStatus.pendingUpload));
    _syncQueue.enqueue(SyncOperation.create(todo));
  }
}

// 3. Sync engine runs when connectivity changes
class SyncEngine {
  final Connectivity _connectivity;

  void init() {
    _connectivity.onConnectivityChanged.listen((status) {
      if (status != ConnectivityResult.none) {
        _processQueue();
      }
    });
  }

  Future<void> _processQueue() async {
    final pending = await _db.getPendingOperations();
    for (final op in pending) {
      try {
        await _api.sync(op);
        await _db.markSynced(op.id);
      } on ConflictException catch (e) {
        await _resolveConflict(op, e.serverVersion);
      }
    }
    // Pull server changes
    final serverChanges = await _api.getChangesSince(_lastSyncTimestamp);
    await _db.mergeServerChanges(serverChanges);
  }
}
Enter fullscreen mode Exit fullscreen mode

Conflict resolution strategies:

  1. Last-write-wins — compare updatedAt timestamps, latest wins. Simple but can lose data.
  2. Field-level merge — merge non-conflicting field changes. Complex but preserves more data.
  3. User resolves — show both versions and let the user pick. Best UX but requires UI.
  4. CRDTs — Conflict-free Replicated Data Types. No conflicts by design, but complex to implement.

Database choice: Drift (SQLite wrapper) for complex queries, or Isar for simpler NoSQL with built-in sync-friendly features.


Q3: How would you handle authentication token refresh transparently across the entire app?

What the interviewer is REALLY testing:
Can you implement an interceptor pattern? Do you understand race conditions when multiple requests hit 401 simultaneously?

Answer:

Use an HTTP interceptor (Dio interceptor) with a token refresh queue.

class AuthInterceptor extends Interceptor {
  final Dio _dio;
  final AuthStorage _storage;

  // Mutex to prevent multiple simultaneous refresh calls
  Completer<String>? _refreshCompleter;

  AuthInterceptor(this._dio, this._storage);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await _storage.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != 401) {
      return handler.next(err);
    }

    // 401 — token expired. Refresh it.
    try {
      final newToken = await _refreshToken();
      // Retry the original request with the new token.
      final options = err.requestOptions;
      options.headers['Authorization'] = 'Bearer $newToken';
      final response = await _dio.fetch(options);
      handler.resolve(response);
    } catch (e) {
      // Refresh failed — force logout.
      await _storage.clear();
      handler.reject(err); // Or navigate to login
    }
  }

  Future<String> _refreshToken() async {
    // If a refresh is already in progress, wait for it.
    if (_refreshCompleter != null) {
      return _refreshCompleter!.future;
    }

    _refreshCompleter = Completer<String>();

    try {
      final refreshToken = await _storage.getRefreshToken();
      // Use a SEPARATE Dio instance to avoid interceptor loop!
      final freshDio = Dio();
      final response = await freshDio.post('/auth/refresh', data: {
        'refresh_token': refreshToken,
      });

      final newAccessToken = response.data['access_token'];
      final newRefreshToken = response.data['refresh_token'];
      await _storage.saveTokens(newAccessToken, newRefreshToken);

      _refreshCompleter!.complete(newAccessToken);
      return newAccessToken;
    } catch (e) {
      _refreshCompleter!.completeError(e);
      rethrow;
    } finally {
      _refreshCompleter = null;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Critical details:

  1. Mutex pattern with Completer — If 5 requests all get 401, only the first triggers a refresh. The other 4 await the same Completer.future.
  2. Separate Dio instance for the refresh call — otherwise the interceptor intercepts the refresh request itself, causing an infinite loop.
  3. Retry the original request after refresh — the user never knows the token expired.
  4. Force logout if refresh fails — the refresh token itself is expired.

Q4: You have a feature that 3 different screens need — where does the shared state live?

What the interviewer is REALLY testing:
Do you understand state scoping, and can you reason about where state belongs in the widget tree?

Answer:

It depends on the relationship between the screens. There are three patterns:

Pattern 1: Common ancestor in widget tree (screens are children of the same navigator)

Place the state above all three screens in the widget tree.

// With Riverpod — state is naturally global/scoped to ProviderScope
final sharedCartProvider = StateNotifierProvider<CartNotifier, CartState>(
  (ref) => CartNotifier(),
);
// All three screens just: ref.watch(sharedCartProvider)

// With BLoC — use BlocProvider above the Navigator
MaterialApp(
  home: BlocProvider(
    create: (_) => CartBloc(),
    child: Navigator(...), // All screens below can access CartBloc
  ),
);
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Screens are in different navigation stacks (e.g., bottom nav tabs)

State must live at the app level, above all navigators.

// Riverpod: Already global by nature. No change needed.

// BLoC: Provide above MaterialApp or above the bottom nav scaffold
MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => SharedFeatureBloc()),
  ],
  child: MaterialApp(home: BottomNavScaffold()),
);
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Screens are in different flows but share data (e.g., user profile)

Use a service/repository layer that the state management reads from.

// The repository is the single source of truth
class UserRepository {
  final _userController = BehaviorSubject<User>();
  Stream<User> get userStream => _userController.stream;

  void updateUser(User user) {
    _api.updateUser(user);
    _userController.add(user); // All listeners get the update
  }
}

// Each screen's BLoC/Provider listens to the repository
final userProvider = StreamProvider<User>(
  (ref) => ref.watch(userRepositoryProvider).userStream,
);
Enter fullscreen mode Exit fullscreen mode

The decision framework:

  • If state is UI-only (tab selection, form input): keep it as local as possible.
  • If state represents domain data (cart, user, orders): put it in a repository and expose via state management.
  • If state must survive screen disposal: it must be above the screen in the tree or in a service layer.

Q5: How do you handle deep linking that requires multiple screens in the back stack?

What the interviewer is REALLY testing:
Do you understand declarative routing vs imperative routing? Have you dealt with the complexity of restoring a navigation stack from a URL?

Answer:

The problem: User clicks a link like myapp://orders/123/item/456. The app should show: Home → Orders → Order #123 → Item #456 — with the full back stack so the user can press back through each screen.

Solution with GoRouter (recommended):

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (_, __) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'orders',
          builder: (_, __) => const OrdersScreen(),
          routes: [
            GoRoute(
              path: ':orderId',
              builder: (_, state) => OrderDetailScreen(
                orderId: state.pathParameters['orderId']!,
              ),
              routes: [
                GoRoute(
                  path: 'item/:itemId',
                  builder: (_, state) => ItemDetailScreen(
                    orderId: state.pathParameters['orderId']!,
                    itemId: state.pathParameters['itemId']!,
                  ),
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
);
Enter fullscreen mode Exit fullscreen mode

GoRouter automatically builds the back stack based on the route nesting. Navigating to /orders/123/item/456 creates the stack: Home → Orders → Order 123 → Item 456.

For more complex scenarios (Navigator 2.0 / custom):

class AppRouterDelegate extends RouterDelegate<AppRoutePath> {
  @override
  Widget build(BuildContext context) {
    return Navigator(
      pages: _buildPageStack(currentPath),
      onPopPage: _handlePop,
    );
  }

  List<Page> _buildPageStack(AppRoutePath path) {
    final pages = <Page>[const MaterialPage(child: HomeScreen())];

    if (path is OrdersPath || path is OrderDetailPath || path is ItemDetailPath) {
      pages.add(const MaterialPage(child: OrdersScreen()));
    }
    if (path is OrderDetailPath || path is ItemDetailPath) {
      pages.add(MaterialPage(child: OrderDetailScreen(orderId: path.orderId)));
    }
    if (path is ItemDetailPath) {
      pages.add(MaterialPage(child: ItemDetailScreen(
        orderId: path.orderId, itemId: path.itemId,
      )));
    }

    return pages;
  }
}
Enter fullscreen mode Exit fullscreen mode

Platform setup required:

  • Android: Intent filters in AndroidManifest.xml + App Links verification.
  • iOS: Associated Domains + Universal Links.
  • Web: URL strategy — usePathUrlStrategy() in main().

Q6: Your team has 10 developers — how do you structure the Flutter project for parallel work?

What the interviewer is REALLY testing:
Do you have experience scaling Flutter teams? Can you think about code organization, merge conflicts, and build times?

Answer:

Use a modular monorepo with feature packages.

my_app/
├── apps/
│   └── my_app/                    # Thin shell app — just wires features together
│       ├── lib/
│       │   ├── main.dart
│       │   ├── app.dart
│       │   └── router.dart
│       └── pubspec.yaml           # depends on all feature packages
├── packages/
│   ├── core/
│   │   ├── core_ui/               # Design system, shared widgets, theme
│   │   ├── core_network/          # Dio setup, interceptors, API client
│   │   ├── core_data/             # Base repository, local DB setup
│   │   └── core_domain/           # Shared models, interfaces
│   └── features/
│       ├── feature_auth/          # Login, signup, forgot password
│       ├── feature_chat/          # Chat feature — owned by Team A
│       ├── feature_profile/       # Profile — owned by Team B
│       ├── feature_orders/        # Orders — owned by Team C
│       └── feature_payments/      # Payments — owned by Team D
└── melos.yaml                     # Monorepo management
Enter fullscreen mode Exit fullscreen mode

Key principles:

  1. Each feature is a separate Dart package with its own pubspec.yaml, tests, and clear API surface.
  2. Features depend on core packages, never on each other. If chat needs user data, it depends on core_domain for the User model, not on feature_profile.
  3. Use Melos for monorepo management — run tests, analyze, and publish across all packages.
  4. Code ownership: Each team owns one or more feature packages. PRs to their packages are reviewed by their team. PRs to core packages require broader review.
  5. Dependency injection at the app level: The shell app wires features together via routing and DI, not direct imports.
# melos.yaml
name: my_app_workspace
packages:
  - apps/**
  - packages/core/**
  - packages/features/**

scripts:
  analyze:
    run: melos exec -- dart analyze .
  test:
    run: melos exec -- flutter test
  test:selective:
    run: melos exec --since=main -- flutter test  # Only test changed packages
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • 10 developers rarely touch the same files → fewer merge conflicts.
  • Each package compiles and tests independently → faster CI.
  • Clear boundaries force clean APIs between features.
  • A developer can work on feature_chat without even knowing feature_payments exists.

Q7: How do you handle version-specific API responses (v1 vs v2) without breaking the app?

What the interviewer is REALLY testing:
Can you design for API evolution? Do you understand backward compatibility, graceful degradation, and the adapter pattern?

Answer:

Use the adapter/strategy pattern with version negotiation.

// 1. Define a stable internal model (domain model)
class User {
  final String id;
  final String displayName;
  final String email;
  User({required this.id, required this.displayName, required this.email});
}

// 2. Version-specific response DTOs and mappers
class UserV1Dto {
  final String id;
  final String firstName;
  final String lastName;
  final String email;

  User toDomain() => User(
    id: id,
    displayName: '$firstName $lastName', // v1 has separate name fields
    email: email,
  );

  factory UserV1Dto.fromJson(Map<String, dynamic> json) => UserV1Dto(
    id: json['id'], firstName: json['first_name'],
    lastName: json['last_name'], email: json['email'],
  );
}

class UserV2Dto {
  final String id;
  final String displayName; // v2 has combined name
  final String email;
  final String? avatarUrl;  // v2 adds avatar

  User toDomain() => User(
    id: id,
    displayName: displayName,
    email: email,
  );

  factory UserV2Dto.fromJson(Map<String, dynamic> json) => UserV2Dto(
    id: json['id'], displayName: json['display_name'],
    email: json['email'], avatarUrl: json['avatar_url'],
  );
}

// 3. API version strategy
abstract class UserApi {
  Future<User> getUser(String id);
}

class UserApiV1 implements UserApi {
  @override
  Future<User> getUser(String id) async {
    final response = await dio.get('/v1/users/$id');
    return UserV1Dto.fromJson(response.data).toDomain();
  }
}

class UserApiV2 implements UserApi {
  @override
  Future<User> getUser(String id) async {
    final response = await dio.get('/v2/users/$id');
    return UserV2Dto.fromJson(response.data).toDomain();
  }
}

// 4. Version negotiation at startup
class ApiVersionResolver {
  Future<UserApi> resolveUserApi() async {
    final config = await remoteConfig.fetch();
    final version = config.getString('user_api_version');
    switch (version) {
      case 'v2': return UserApiV2();
      default: return UserApiV1(); // Fallback to v1
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The key principle: Your domain models and UI never know which API version is in use. The adapter layer absorbs all version differences.


Q8: When would you choose Method Channel over FFI? Give a real-world scenario.

What the interviewer is REALLY testing:
Do you understand the technical differences and when platform-specific APIs are unavoidable?

Answer:

Aspect Method Channel dart:ffi
What it calls Platform APIs (Java/Kotlin, ObjC/Swift) C/C++ native libraries
Async Yes (message passing across thread boundary) No (synchronous, same thread)
Platform UI access Yes (can access Activities, UIViewControllers) No
Performance Overhead per call (serialization) Near-zero overhead
Use case Platform services, sensors, OS features Math, crypto, image processing

Choose Method Channel — real-world scenario: Biometric authentication.

You need to access Face ID on iOS (LocalAuthentication framework) and Fingerprint on Android (BiometricPrompt API). These are platform UI APIs — they show system dialogs, interact with the OS security enclave, and require platform-specific lifecycle handling.

// Dart side
static const platform = MethodChannel('com.app/biometric');

Future<bool> authenticate() async {
  final result = await platform.invokeMethod<bool>('authenticate', {
    'reason': 'Verify your identity',
  });
  return result ?? false;
}

// Kotlin side (Android)
class BiometricPlugin : MethodCallHandler {
  override fun onMethodCall(call: MethodCall, result: Result) {
    if (call.method == "authenticate") {
      val prompt = BiometricPrompt(activity, executor, callback)
      prompt.authenticate(promptInfo) // Shows system UI
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

FFI cannot do this — there is no C library for Face ID or Android BiometricPrompt.

Choose FFI — real-world scenario: Image processing with OpenCV.

You have a C++ library for real-time image filters. Each frame needs processing in under 16ms. Method Channel's serialization overhead would kill performance.

// Direct FFI call — no serialization, no async overhead
final dylib = DynamicLibrary.open('libimage_processor.so');
final processFrame = dylib.lookupFunction<
  Void Function(Pointer<Uint8>, Int32, Int32),
  void Function(Pointer<Uint8>, int, int)
>('process_frame');

processFrame(framePointer, width, height); // Microseconds, not milliseconds
Enter fullscreen mode Exit fullscreen mode

Q9: How would you design a plugin that works on iOS, Android, Web, and Desktop?

What the interviewer is REALLY testing:
Do you understand the federated plugin architecture? Have you built or studied multi-platform plugins?

Answer:

Use Flutter's federated plugin architecture. One plugin, multiple platform packages.

my_plugin/
├── my_plugin/                          # App-facing package (what users import)
│   ├── lib/
│   │   └── my_plugin.dart              # Delegates to platform interface
│   └── pubspec.yaml
├── my_plugin_platform_interface/       # Abstract contract
│   ├── lib/
│   │   ├── my_plugin_platform_interface.dart
│   │   └── method_channel_my_plugin.dart  # Default MethodChannel implementation
│   └── pubspec.yaml
├── my_plugin_android/                  # Android implementation
│   ├── android/
│   ├── lib/my_plugin_android.dart
│   └── pubspec.yaml
├── my_plugin_ios/                      # iOS implementation
│   ├── ios/
│   ├── lib/my_plugin_ios.dart
│   └── pubspec.yaml
├── my_plugin_web/                      # Web implementation
│   ├── lib/my_plugin_web.dart          # Uses dart:js_interop
│   └── pubspec.yaml
└── my_plugin_desktop/                  # Desktop (Windows/macOS/Linux)
    ├── lib/my_plugin_desktop.dart      # Uses FFI
    └── pubspec.yaml
Enter fullscreen mode Exit fullscreen mode
// Platform interface — the contract
abstract class MyPluginPlatform extends PlatformInterface {
  static MyPluginPlatform _instance = MethodChannelMyPlugin();
  static MyPluginPlatform get instance => _instance;
  static set instance(MyPluginPlatform instance) {
    PlatformInterface.verify(instance, _token);
    _instance = instance;
  }

  Future<String> getPlatformInfo();
}

// App-facing API — clean and simple
class MyPlugin {
  Future<String> getPlatformInfo() {
    return MyPluginPlatform.instance.getPlatformInfo();
  }
}

// Android implementation — registers itself
class MyPluginAndroid extends MyPluginPlatform {
  static void registerWith() {
    MyPluginPlatform.instance = MyPluginAndroid();
  }

  @override
  Future<String> getPlatformInfo() async {
    // MethodChannel call to Kotlin code
    return await methodChannel.invokeMethod('getPlatformInfo');
  }
}

// Web implementation — uses JS interop
class MyPluginWeb extends MyPluginPlatform {
  static void registerWith(Registrar registrar) {
    MyPluginPlatform.instance = MyPluginWeb();
  }

  @override
  Future<String> getPlatformInfo() async {
    return js_util.callMethod(window, 'getPlatformInfo', []);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why federated?

  • Teams can maintain platform implementations independently.
  • Adding a new platform does not touch existing code.
  • Users only pull in the platforms they target — no unused native code.

Q10: Your app is 80MB — the PM wants it under 30MB. What do you cut and how?

What the interviewer is REALLY testing:
Do you understand what contributes to Flutter app size? Have you actually optimized an APK/IPA?

Answer:

Step 1: Analyze what is taking space.

# Build with size analysis
flutter build apk --analyze-size
flutter build appbundle --analyze-size

# Opens a treemap in the browser showing exactly what takes space
Enter fullscreen mode Exit fullscreen mode

Step 2: The biggest offenders and how to fix each.

Offender Typical Size Fix
Native libraries (Flutter engine) ~5-8MB Cannot reduce. This is the floor.
Assets (images, fonts, animations) 10-40MB Biggest opportunity. See below.
Dart code (compiled) 2-5MB Tree shaking, deferred loading
Unused native plugins 1-5MB each Remove unused plugins from pubspec
Font files 1-5MB Use only needed font weights
Debug symbols 10-20MB --split-debug-info strips them

Concrete actions:

# 1. Split debug info (saves 10-20MB immediately)
flutter build apk --split-debug-info=./debug-symbols --obfuscate

# 2. Use App Bundles instead of APK (Google Play delivers only needed ABIs)
flutter build appbundle  # Instead of flutter build apk
# An APK contains arm64 + arm32 + x86. App bundle delivers only the needed one.
# Saves ~30-40% of native library size.

# 3. Use --split-per-abi for APKs
flutter build apk --split-per-abi
# Produces separate APKs: app-arm64-v8a.apk (~15MB smaller), etc.
Enter fullscreen mode Exit fullscreen mode
// 4. Deferred imports — load features on demand
import 'package:app/features/reports/reports.dart' deferred as reports;

Future<void> showReports() async {
  await reports.loadLibrary();
  navigator.push(reports.ReportsScreen());
}
Enter fullscreen mode Exit fullscreen mode

5. Asset optimization:

  • Compress PNGs with pngquant (60-80% reduction).
  • Use WebP instead of PNG/JPEG (30-50% smaller).
  • Use Lottie JSON instead of GIF animations (90% smaller).
  • Use SVGs via flutter_svg instead of multi-resolution PNGs.
  • Download large assets on demand instead of bundling.
  • Remove unused assets — audit pubspec.yaml assets section.

6. Font optimization:

# Instead of including all weights:
fonts:
  - family: Roboto
    fonts:
      - asset: fonts/Roboto-Regular.ttf  # Only weights you actually use
      - asset: fonts/Roboto-Bold.ttf
        weight: 700
# Remove Roboto-Thin, Light, Medium, Black, etc.
Enter fullscreen mode Exit fullscreen mode

7. Audit dependencies:

# Check which packages pull in native code
flutter pub deps
# Each native plugin adds native libraries. Remove unused ones.
# Common bloat: unused Firebase packages, camera plugin when not needed, etc.
Enter fullscreen mode Exit fullscreen mode

Realistic target: A well-optimized Flutter app with moderate features should be 15-25MB as an app bundle.


SECTION 3: DEBUGGING & "WHAT WENT WRONG?" SCENARIOS


Q1: Your app works in debug but crashes in release mode — what could cause this?

What the interviewer is REALLY testing:
Do you understand the differences between debug and release compilation? Have you dealt with release-only bugs?

Answer:

Top causes, from most common to least:

1. ProGuard/R8 code shrinking (Android)
Release mode enables R8 (code shrinking/obfuscation). It removes "unused" code — but sometimes removes code that is used via reflection (JSON serialization, method channels).

// android/app/build.gradle — check these
buildTypes {
    release {
        shrinkResources true
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

// Fix: Add keep rules in proguard-rules.pro
-keep class com.yourpackage.** { *; }
-keep class io.flutter.** { *; }
Enter fullscreen mode Exit fullscreen mode

2. Missing Internet permission (Android)
Debug mode implicitly allows internet. Release mode on some devices requires explicit permission.

<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
Enter fullscreen mode Exit fullscreen mode

3. Tree shaking removes code used via reflection
dart:mirrors is not available in release. If any dependency uses it, it breaks.

4. Timing-dependent race conditions
Debug mode is slower (JIT, assertions, debug checks). Release mode is AOT-compiled and faster. Race conditions that were hidden by debug slowness now manifest.

// This might "work" in debug but fail in release:
late final data = fetchData(); // Timing may differ
Enter fullscreen mode Exit fullscreen mode

5. Assertion removal
assert() statements run in debug, are removed in release.

assert(list.isNotEmpty); // Runs in debug, removed in release
doSomething(list.first); // Crashes in release if list is empty
Enter fullscreen mode Exit fullscreen mode

6. iOS App Transport Security
Debug builds may bypass ATS. Release builds enforce HTTPS. HTTP URLs fail silently.

7. dart:developer usage
Timeline, debugger(), and other dart:developer features behave differently in release.

Debugging strategy:

# Build release with debug symbols for crash logs
flutter build apk --release --split-debug-info=./symbols --obfuscate
# Use symbols to decode stack traces:
flutter symbolize -i crash_log.txt -d ./symbols
Enter fullscreen mode Exit fullscreen mode

Q2: setState is called but UI doesn't update — list all possible reasons.

What the interviewer is REALLY testing:
Do you understand the widget lifecycle, state management, and how Flutter's rendering pipeline works?

Answer:

1. Mutating an object without changing its reference

List<String> items = ['a', 'b'];

void addItem() {
  setState(() {
    items.add('c'); // Same list reference — some widgets won't detect the change
  });
}

// Fix: Create a new list
setState(() {
  items = [...items, 'c'];
});
Enter fullscreen mode Exit fullscreen mode

2. The widget is not actually rebuilt
The State's build method runs, but the part that reads the changed value is in a child widget that has its own state and is not being rebuilt.

3. setState called on a widget that is not mounted

// No visible error in some cases, setState is just ignored if !mounted
if (mounted) {
  setState(() { ... });
}
Enter fullscreen mode Exit fullscreen mode

4. The value changed but the widget tree compares identical
Flutter's reconciliation may skip a subtree if the widget's operator == returns true (e.g., const widgets, or widgets with @immutable that look the same).

5. Using the wrong BuildContext
If you have nested navigators or overlays, the context might not be in the tree you think it is.

6. Animation/async gap — the value updates after build completes

void loadData() {
  setState(() { isLoading = true; });
  fetchData().then((data) {
    // If you forgot setState here, UI shows loading forever
    setState(() {
      this.data = data;
      isLoading = false;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

7. Keys causing widget recreation instead of update
Using UniqueKey() forces a fresh State, losing your changes. Using no key when you should causes Flutter to reuse the wrong State.

8. State is in the wrong place
The value is stored in a parent widget's state but the child calls setState on itself — which rebuilds only the child, not the parent that holds the value.

9. Overridden shouldRebuild or updateShouldNotify
Custom InheritedWidget with updateShouldNotify returning false. Custom delegate with shouldRebuild returning false.


Q3: Hot reload doesn't work / shows old UI — why?

What the interviewer is REALLY testing:
Do you know the limitations of hot reload? This is a practical day-to-day issue.

Answer:

Hot reload CANNOT handle these changes — you need hot restart or full restart:

  1. Changes to main() — app entry point is not re-executed on hot reload.
  2. Changes to global variables / static fields — they keep their old values.
  3. Changes to initState() — already called, will not be called again.
  4. Enum changes — adding/removing/reordering enum values.
  5. Generic type changes — changing type parameters.
  6. Changes from StatelessWidget to StatefulWidget (or vice versa).
  7. Changes to native code (Kotlin/Swift) — requires full rebuild.
  8. Changes to const constructors — const values are inlined at compile time.
  9. Adding/removing/renaming classes — sometimes fails gracefully, sometimes not.

Other causes of stale UI:

  • IDE not saving the file — hot reload triggers on save.
  • Syntax error in the file — hot reload fails silently (check the console).
  • Plugin code changed — only Dart code hot reloads.
  • Build flavor mismatch — running the wrong configuration.

The fix:

  • r in terminal = hot reload.
  • R in terminal = hot restart (resets state, re-runs main()).
  • Stop and rebuild = nuclear option for native code changes.

Q4: "A RenderFlex overflowed by X pixels" — what causes this and 5 ways to fix it?

What the interviewer is REALLY testing:
This is the most common Flutter layout error. Can you explain WHY it happens at the rendering level, not just how to suppress it?

Answer:

Why it happens:
A Row or Column (which use RenderFlex) has children whose total size along the main axis exceeds the available space. The RenderFlex cannot lay out its children within the given constraints.

Common trigger: Long text, too many items, or a non-scrollable container with oversized content.

5 ways to fix:

1. Wrap overflowing children in Flexible or Expanded

// BROKEN:
Row(children: [Text('Very long text that overflows'), Icon(Icons.star)])

// FIXED:
Row(children: [Expanded(child: Text('Very long text...', overflow: TextOverflow.ellipsis)), Icon(Icons.star)])
Enter fullscreen mode Exit fullscreen mode

2. Make the container scrollable

// BROKEN:
Column(children: List.generate(100, (i) => ListTile(title: Text('Item $i'))))

// FIXED:
SingleChildScrollView(
  child: Column(children: List.generate(100, (i) => ListTile(title: Text('Item $i')))),
)
// Or better: use ListView instead of Column
Enter fullscreen mode Exit fullscreen mode

3. Use overflow / TextOverflow to truncate

Text('Long text', overflow: TextOverflow.ellipsis, maxLines: 1)
Enter fullscreen mode Exit fullscreen mode

4. Wrap children using Wrap instead of Row

// BROKEN: Row with too many chips
Row(children: chips) // Overflows if too many

// FIXED: Wrap flows to next line
Wrap(spacing: 8, runSpacing: 4, children: chips)
Enter fullscreen mode Exit fullscreen mode

5. Use ConstrainedBox, SizedBox, or FractionallySizedBox to limit size

ConstrainedBox(
  constraints: BoxConstraints(maxWidth: 200),
  child: Text('This text will wrap within 200px instead of overflowing'),
)
Enter fullscreen mode Exit fullscreen mode

Q5: "setState() called after dispose()" — why does this happen and how to fix properly?

What the interviewer is REALLY testing:
Do you understand async gaps and widget lifecycle? This is one of the most common real-world bugs.

Answer:

Why it happens: An async operation (Future, Timer, Stream) completes after the widget has been removed from the tree. The callback tries to call setState on a disposed State.

class MyWidget extends StatefulWidget { ... }

class _MyWidgetState extends State<MyWidget> {
  String data = '';

  @override
  void initState() {
    super.initState();
    fetchData().then((result) {
      setState(() { data = result; }); // CRASH if widget was disposed during fetch
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Fix 1: Check mounted (simplest)

fetchData().then((result) {
  if (!mounted) return; // Widget is gone, do nothing
  setState(() { data = result; });
});
Enter fullscreen mode Exit fullscreen mode

Fix 2: Cancel the operation in dispose (proper)

class _MyWidgetState extends State<MyWidget> {
  StreamSubscription? _sub;
  Timer? _timer;

  @override
  void initState() {
    super.initState();
    _sub = myStream.listen((data) {
      setState(() { this.data = data; });
    });
    _timer = Timer.periodic(Duration(seconds: 5), (_) {
      setState(() { /* update */ });
    });
  }

  @override
  void dispose() {
    _sub?.cancel();      // Cancel stream subscription
    _timer?.cancel();     // Cancel timer
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Fix 3: Use CancelableOperation from package:async

late CancelableOperation<String> _operation;

@override
void initState() {
  super.initState();
  _operation = CancelableOperation.fromFuture(fetchData());
  _operation.value.then((result) {
    setState(() { data = result; });
  });
}

@override
void dispose() {
  _operation.cancel();
  super.dispose();
}
Enter fullscreen mode Exit fullscreen mode

The root cause is always the same: you have a reference from an async closure back to a State that no longer exists. The fix is always: either guard with mounted or cancel the async work.


Q6: The app is slow only on first launch — what causes this?

What the interviewer is REALLY testing:
Do you understand Dart compilation, shader compilation, and cold start optimization?

Answer:

1. Shader compilation jank (the #1 cause)
Flutter compiles GPU shaders on first use. The first time a visual effect is rendered, there is a visible jank/stutter.

# Fix: Pre-warm shaders
# Step 1: Capture shaders during a test run
flutter drive --profile --cache-sksl --write-sksl-on-exit flutter_01.sksl.json

# Step 2: Bundle them with the build
flutter build apk --bundle-sksl-warmup flutter_01.sksl.json
Enter fullscreen mode Exit fullscreen mode

With Impeller (default on iOS, coming to Android), shader compilation jank is largely eliminated.

2. Large synchronous initialization in main()

// SLOW — blocks the first frame
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();         // Network call
  await Hive.initFlutter();               // Disk I/O
  await loadAllConfig();                  // More I/O
  await preloadImages();                  // Image decoding
  runApp(MyApp());
}

// FAST — show UI immediately, load in background
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(MyApp()); // Show splash immediately
}

// Inside MyApp, use FutureBuilder or splash screen to init in parallel
Enter fullscreen mode Exit fullscreen mode

3. Dart VM startup and AOT compilation
The first launch loads the AOT-compiled snapshot into memory. Subsequent launches benefit from OS-level caching.

4. Large asset loading
If initState loads large JSON, databases, or images synchronously, the first frame is delayed.

5. Plugin initialization
Firebase, Google Maps, and other plugins with native initialization add to cold start time.

Measurement:

# Measure startup time
flutter run --trace-startup
# Generates a timeline in build/start_up_info.json
# Shows time to: engine init, framework init, first frame
Enter fullscreen mode Exit fullscreen mode

Q7: Images are blurry on some devices — why?

What the interviewer is REALLY testing:
Do you understand device pixel ratio, asset resolution, and how Flutter selects image assets?

Answer:

Cause: The image resolution does not match the device pixel ratio (DPR). A device with DPR 3.0 displaying a 1x image scales it up → blurry.

How Flutter selects images:

assets/
  images/
    logo.png          ← 1x (100x100 px)
    2.0x/logo.png     ← 2x (200x200 px)
    3.0x/logo.png     ← 3x (300x300 px)
    4.0x/logo.png     ← 4x (400x400 px)
Enter fullscreen mode Exit fullscreen mode

Flutter picks the variant closest to (but not less than) the device's pixel ratio. If the device has DPR 3.0 and you only have 1x, it stretches 100px → 300px → blurry.

Fixes:

  1. Provide all density variants:
flutter:
  assets:
    - assets/images/
    - assets/images/2.0x/
    - assets/images/3.0x/
Enter fullscreen mode Exit fullscreen mode
  1. Use SVG for icons and simple graphics:
SvgPicture.asset('assets/logo.svg') // Infinitely scalable, never blurry
Enter fullscreen mode Exit fullscreen mode
  1. For network images, request the right resolution:
final dpr = MediaQuery.of(context).devicePixelRatio;
final width = 100 * dpr; // Request appropriate size from CDN
Image.network('https://cdn.example.com/photo.jpg?w=${width.toInt()}')
Enter fullscreen mode Exit fullscreen mode
  1. Set filterQuality for better scaling:
Image.asset('assets/photo.png', filterQuality: FilterQuality.high)
Enter fullscreen mode Exit fullscreen mode

Q8: "Looking up a deactivated widget's ancestor is unsafe" — what causes this?

What the interviewer is REALLY testing:
Do you understand widget lifecycle states (active, deactivated, disposed) and when context becomes invalid?

Answer:

Cause: You are using a BuildContext after its widget has been removed from the tree but before it is fully disposed. This typically happens in async callbacks after navigation.

// BROKEN:
onPressed: () async {
  await someAsyncWork();
  // By now, the user may have navigated away. This widget is deactivated.
  Navigator.of(context).push(...); // CRASH — context is deactivated
  ScaffoldMessenger.of(context).showSnackBar(...); // CRASH
}
Enter fullscreen mode Exit fullscreen mode

Why "deactivated" specifically: When a widget is removed from the tree (e.g., navigated away), Flutter deactivates it before disposing. During deactivation, the widget's ancestors are being unlinked. Looking up an ancestor at this point could return stale or inconsistent data.

Fixes:

// Fix 1: Check mounted
onPressed: () async {
  await someAsyncWork();
  if (!context.mounted) return; // Dart 3.7+ — context.mounted
  Navigator.of(context).push(...);
}

// Fix 2: Capture the navigator before the async gap
onPressed: () async {
  final navigator = Navigator.of(context); // Capture BEFORE await
  final messenger = ScaffoldMessenger.of(context);
  await someAsyncWork();
  navigator.push(...); // Use captured reference
  messenger.showSnackBar(...);
}

// Fix 3: Use a GlobalKey (for edge cases)
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
// ...
scaffoldKey.currentState?.showSnackBar(...);
Enter fullscreen mode Exit fullscreen mode

Q9: Your HTTP request works on Android but fails on iOS — what went wrong?

What the interviewer is REALLY testing:
Do you know platform-specific networking restrictions? This is a classic production gotcha.

Answer:

Cause #1 (most common): App Transport Security (ATS)
iOS blocks all HTTP (non-HTTPS) requests by default. Android does not (below API 28).

<!-- ios/Runner/Info.plist — allow HTTP (not recommended for production) -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>

<!-- Better: allow specific domains -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSExceptionDomains</key>
  <dict>
    <key>example.com</key>
    <dict>
      <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
      <true/>
    </dict>
  </dict>
</dict>
Enter fullscreen mode Exit fullscreen mode

Cause #2: Self-signed SSL certificates
iOS is stricter about certificate validation. Self-signed certs that work on Android fail on iOS.

// Workaround for development (NEVER in production):
HttpOverrides.global = MyHttpOverrides();

class MyHttpOverrides extends HttpOverrides {
  @override
  HttpClient createHttpClient(SecurityContext? context) {
    return super.createHttpClient(context)
      ..badCertificateCallback = (cert, host, port) => true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Cause #3: Missing capabilities in iOS
Some network features require specific entitlements in Xcode (e.g., Multicast, VPN).

Cause #4: TLS version mismatch
Your server uses TLS 1.0/1.1. iOS 15+ requires TLS 1.2 minimum.

Cause #5: IPv6 requirement
Apple requires IPv6 compatibility for App Store. If your server is IPv4-only, it may fail on certain iOS networks.


Q10: Keyboard opens and causes overflow — how to handle?

What the interviewer is REALLY testing:
Do you understand how keyboard insets work and how Scaffold handles them?

Answer:

Why it happens: When the keyboard appears, it pushes the content up by default (resizeToAvoidBottomInset: true). If the layout is not flexible, content overflows.

Fix 1: Wrap in SingleChildScrollView

Scaffold(
  body: SingleChildScrollView(
    child: Column(
      children: [
        // ...form fields
      ],
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

Fix 2: Disable resize (content goes behind keyboard)

Scaffold(
  resizeToAvoidBottomInset: false, // Keyboard overlaps content
  body: ...,
)
Enter fullscreen mode Exit fullscreen mode

Fix 3: Use MediaQuery.of(context).viewInsets.bottom for manual padding

Padding(
  padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
  child: ...,
)
Enter fullscreen mode Exit fullscreen mode

Fix 4: Scroll to the focused field automatically

// Using Scrollable.ensureVisible:
FocusNode _focusNode = FocusNode();

TextField(
  focusNode: _focusNode,
  onTap: () {
    Future.delayed(Duration(milliseconds: 300), () {
      Scrollable.ensureVisible(
        context,
        duration: Duration(milliseconds: 300),
        curve: Curves.easeInOut,
      );
    });
  },
)
Enter fullscreen mode Exit fullscreen mode

Fix 5: Use ListView instead of Column — it is scrollable by default.


Q11: "Vertical viewport was given unbounded height" — explain why and all ways to fix.

What the interviewer is REALLY testing:
Do you understand Flutter's constraint system and why scrollables need bounded constraints?

Answer:

Why it happens: A ListView (or any scrollable) is placed inside a parent that gives it unbounded height (infinite). The ListView needs to know its viewport height to calculate how many items to render, but it receives double.infinity.

The classic trigger:

// CRASH: Column gives unbounded height to children in the main axis
Column(
  children: [
    ListView( // Tries to be infinitely tall inside an already-infinite column
      children: [...],
    ),
  ],
)
Enter fullscreen mode Exit fullscreen mode

Why Column causes this: A Column lays out non-flex children first with unbounded main-axis constraints. ListView receives maxHeight: infinity, panics because it cannot create a viewport without a finite height.

All fixes:

1. Wrap ListView in Expanded (most common)

Column(
  children: [
    Text('Header'),
    Expanded(
      child: ListView(children: [...]),
    ),
  ],
)
Enter fullscreen mode Exit fullscreen mode

2. Give ListView a fixed height with SizedBox

Column(
  children: [
    SizedBox(
      height: 300,
      child: ListView(children: [...]),
    ),
  ],
)
Enter fullscreen mode Exit fullscreen mode

3. Use shrinkWrap: true (use sparingly — kills virtualization)

Column(
  children: [
    ListView(
      shrinkWrap: true, // ListView sizes itself to its content
      physics: NeverScrollableScrollPhysics(), // Disable inner scrolling
      children: [...],
    ),
  ],
)
// WARNING: shrinkWrap builds ALL items. No lazy loading. Bad for long lists.
Enter fullscreen mode Exit fullscreen mode

4. Use Flexible instead of Expanded

Column(
  children: [
    Flexible(
      child: ListView(children: [...]),
    ),
  ],
)
// Flexible lets ListView take remaining space but can also be smaller.
Enter fullscreen mode Exit fullscreen mode

5. Replace Column + ListView with a single CustomScrollView

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(child: Text('Header')),
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ListTile(title: Text('Item $index')),
        childCount: 100,
      ),
    ),
  ],
)
// Best solution: single scrollable, fully virtualized.
Enter fullscreen mode Exit fullscreen mode

Q12: Firebase notifications work in foreground but not background — why?

What the interviewer is REALLY testing:
Do you understand the different execution contexts for push notifications on iOS and Android?

Answer:

Android causes:

  1. Background handler not registered as a top-level function.
// BROKEN — inside a class or closure
class App {
  void handler(RemoteMessage msg) {} // Not top-level
}

// FIXED — must be a top-level function (not a method, not a closure)
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  print('Background message: ${message.messageId}');
}

void main() {
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  runApp(MyApp());
}
Enter fullscreen mode Exit fullscreen mode
  1. Missing Firebase.initializeApp() in background handler. The background handler runs in a separate isolate — Firebase is not initialized there.

  2. Battery optimization killing the app. Some OEMs (Xiaomi, Huawei, Samsung) aggressively kill background processes.

iOS causes:

  1. Missing background modes capability.

    • In Xcode: Signing & Capabilities → Background Modes → check "Remote notifications".
  2. Missing content-available: 1 in the push payload. iOS needs this flag for silent/background notifications.

  3. APNs configuration. iOS uses APNs, not FCM directly. Missing APNs key/certificate → no push in any mode (but sometimes foreground works through FCM fallback).

  4. Not calling FirebaseMessaging.instance.requestPermission() on iOS 10+.

// iOS requires explicit permission request
final settings = await FirebaseMessaging.instance.requestPermission(
  alert: true,
  badge: true,
  sound: true,
);
Enter fullscreen mode Exit fullscreen mode
  1. Provisional notifications. iOS 12+ has provisional (quiet) notifications that do not show banners — the user may think notifications are not working.

Q13: App works on emulator but crashes on real device — causes?

What the interviewer is REALLY testing:
Do you understand the differences between emulator and device environments?

Answer:

  1. Missing permissions at runtime. Emulators often auto-grant permissions. Real devices require runtime requests.
// Must request at runtime on real devices
final status = await Permission.camera.request();
if (!status.isGranted) { /* handle denial */ }
Enter fullscreen mode Exit fullscreen mode
  1. Architecture mismatch. Emulator runs x86/x86_64. Real device runs ARM. Native libraries (.so) compiled only for x86 will crash on ARM.

  2. No internet permission in manifest (Android). Some emulators bypass this. Real devices enforce it.

  3. SSL/certificate issues. Emulator may trust all certs or connect to localhost. Real device cannot reach 10.0.2.2 (emulator-specific host alias).

  4. Memory constraints. Emulator has generous RAM. Real device (especially low-end) may OOM on large image processing.

  5. GPS/sensors not available. Code that accesses accelerometer, gyroscope, or GPS without null checks crashes on devices that lack those sensors.

  6. Screen size/density differences. Emulator has a fixed size. Real device may have a notch, rounded corners, or extreme aspect ratio causing layout overflow.

  7. File system differences. Emulator's file system may be more permissive. Real device may have scoped storage restrictions (Android 11+).

  8. Background execution limits. Emulator may allow unlimited background work. Real device has battery optimization that kills services.

  9. OS version differences. Emulator might run API 34. Real device might be on API 26 with different behaviors.


Q14: Your BLoC listener fires twice — why?

What the interviewer is REALLY testing:
Do you understand BLoC stream mechanics and common setup mistakes?

Answer:

Cause 1: BLoC is provided above a widget that rebuilds, creating a new BLoC each time.

// BROKEN — new Bloc created on every build
Widget build(BuildContext context) {
  return BlocProvider(
    create: (_) => MyBloc(), // New BLoC every rebuild!
    child: MyScreen(),
  );
}

// FIXED — provide higher up, or use BlocProvider.value
Enter fullscreen mode Exit fullscreen mode

Cause 2: Multiple BlocListeners for the same BLoC.

// Accidentally nested two listeners
BlocListener<MyBloc, MyState>(
  listener: (ctx, state) { /* fires once */ },
  child: BlocListener<MyBloc, MyState>( // DUPLICATE
    listener: (ctx, state) { /* fires again */ },
    child: MyWidget(),
  ),
)
Enter fullscreen mode Exit fullscreen mode

Cause 3: BLoC emits the same state twice.
By default, bloc uses == to deduplicate consecutive states. If your state class does not override ==, every emission is "different" even if the values are the same.

// BROKEN — default == checks identity, so every emission triggers listener
class MyState {
  final String data;
  MyState(this.data);
}

// FIXED — override == and hashCode (or use Equatable)
class MyState extends Equatable {
  final String data;
  const MyState(this.data);
  @override
  List<Object?> get props => [data];
}
Enter fullscreen mode Exit fullscreen mode

Cause 4: Using BlocBuilder + BlocListener separately instead of BlocConsumer.
Not a direct cause of double-fire, but sometimes the listener placement causes confusion.

Cause 5: Hot reload re-registers the listener.
After hot reload, initState does not re-run, but the widget tree rebuilds. If the listener is in the build method, it may re-register.


Q15: context.read inside build causes issues but context.watch doesn't — explain.

What the interviewer is REALLY testing:
Do you understand the difference between one-time reads and reactive subscriptions in Provider?

Answer:

// context.watch — REACTIVE. Subscribes to changes. Rebuilds when the value changes.
// CORRECT in build():
Widget build(BuildContext context) {
  final count = context.watch<Counter>().value; // Rebuilds when count changes
  return Text('$count');
}

// context.read — ONE-TIME read. Does NOT subscribe. Does NOT trigger rebuilds.
// WRONG in build():
Widget build(BuildContext context) {
  final count = context.read<Counter>().value; // Reads once, NEVER updates
  return Text('$count'); // Shows stale value forever
}
Enter fullscreen mode Exit fullscreen mode

Why context.read in build is wrong:

  • build is called to produce the current UI for the current state.
  • context.read reads the value but does not register a dependency. When the value changes, this widget is NOT rebuilt → stale UI.
  • Provider's context.read (and Riverpod's ref.read) explicitly throw in debug mode if used inside build to prevent this mistake.

When context.read IS correct:

// In callbacks — NOT during build, but in response to user action
ElevatedButton(
  onPressed: () {
    context.read<Counter>().increment(); // One-time action, not a subscription
  },
  child: Text('Increment'),
)
Enter fullscreen mode Exit fullscreen mode

The rule:

  • watch = "I need this value to build my UI. Rebuild me when it changes."
  • read = "I need this value right now for a one-time action. Do not subscribe."
  • select = "I need only part of this value. Rebuild only when that part changes."
// select — optimized watch
Widget build(BuildContext context) {
  // Only rebuilds when .name changes, not when other User fields change
  final name = context.select<UserProvider, String>((user) => user.name);
  return Text(name);
}
Enter fullscreen mode Exit fullscreen mode

Q16: Your app's scroll performance is terrible — how do you diagnose and fix it?

What the interviewer is REALLY testing:
Do you know how to profile Flutter apps and understand the rendering pipeline?

Answer:

Step 1: Diagnose with DevTools.

flutter run --profile  # Always profile in profile mode, never debug
Enter fullscreen mode Exit fullscreen mode

Open Flutter DevTools → Performance tab → look for:

  • Red frames — jank (frames taking >16ms).
  • Shader compilation bars — one-time cost on first render.
  • Build phase too long — too many widgets rebuilding.
  • Paint phase too long — complex painting operations.

Step 2: Common causes and fixes.

Cause 1: Rebuilding too many widgets.

// BROKEN — entire list rebuilds when one item changes
ListView.builder(
  itemBuilder: (ctx, i) {
    final item = context.watch<ItemList>().items[i]; // Watches entire list
    return ListTile(title: Text(item.name));
  },
)

// FIXED — each item watches only its own data
ListView.builder(
  itemBuilder: (ctx, i) => ItemWidget(index: i), // Separate widget
)

class ItemWidget extends StatelessWidget {
  final int index;
  const ItemWidget({required this.index});

  @override
  Widget build(BuildContext context) {
    final item = context.select<ItemList, Item>((list) => list.items[index]);
    return ListTile(title: Text(item.name));
  }
}
Enter fullscreen mode Exit fullscreen mode

Cause 2: Expensive itemBuilder.

// BROKEN — creating complex widgets in builder
itemBuilder: (ctx, i) {
  final image = processImage(data[i].rawBytes); // CPU-intensive in build!
  return ComplexCard(image: image);
}

// FIXED — pre-process data, cache images
Enter fullscreen mode Exit fullscreen mode

Cause 3: Not using const constructors.

// BROKEN
ListTile(leading: Icon(Icons.star), title: Text('Hello'))

// FIXED — const prevents unnecessary rebuilds
const ListTile(leading: Icon(Icons.star), title: Text('Hello'))
Enter fullscreen mode Exit fullscreen mode

Cause 4: Heavy images without caching.

// Use CachedNetworkImage instead of Image.network
CachedNetworkImage(
  imageUrl: url,
  placeholder: (ctx, url) => const CircularProgressIndicator(),
  memCacheWidth: 200, // Resize in memory to reduce GPU load
)
Enter fullscreen mode Exit fullscreen mode

Cause 5: Using shrinkWrap: true on long lists. Disables virtualization — all items are built at once.

Cause 6: Missing RepaintBoundary.

// Isolate frequently changing widgets from static ones
RepaintBoundary(
  child: AnimatedWidget(), // Only this repaints, not the entire list
)
Enter fullscreen mode Exit fullscreen mode

SECTION 4: CODE OUTPUT / PREDICT THE OUTPUT


Q1: FutureBuilder Rebuilding Patterns

What's the output?

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('build called');
    return FutureBuilder<String>(
      future: Future.delayed(Duration(seconds: 1), () => 'Hello'),
      builder: (context, snapshot) {
        print('builder: ${snapshot.connectionState}');
        return Text(snapshot.data ?? 'Loading...');
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Answer:

If the parent rebuilds this widget, you see:

build called
builder: ConnectionState.waiting
// After 1 second:
builder: ConnectionState.done

// If parent triggers rebuild (e.g., setState higher up):
build called
builder: ConnectionState.waiting    // AGAIN! New Future created!
// After 1 second:
builder: ConnectionState.done       // AGAIN!
Enter fullscreen mode Exit fullscreen mode

Why the trap: Every time build runs, Future.delayed(...) creates a new Future. FutureBuilder sees a different Future object and resubscribes, showing waiting again.

The fix:

class MyWidget extends StatefulWidget { ... }

class _MyWidgetState extends State<MyWidget> {
  late final Future<String> _future;

  @override
  void initState() {
    super.initState();
    _future = Future.delayed(Duration(seconds: 1), () => 'Hello');
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<String>(
      future: _future, // Same Future object across rebuilds
      builder: (context, snapshot) {
        print('builder: ${snapshot.connectionState}');
        return Text(snapshot.data ?? 'Loading...');
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now parent rebuilds do NOT restart the Future.


Q2: setState with Async Gap

What's the output?

class CounterWidget extends StatefulWidget { ... }

class _CounterWidgetState extends State<CounterWidget> {
  int count = 0;

  void increment() async {
    setState(() { count++; });
    print('A: $count');

    await Future.delayed(Duration.zero);

    setState(() { count++; });
    print('B: $count');
  }

  @override
  Widget build(BuildContext context) {
    print('Build: $count');
    return ElevatedButton(onPressed: increment, child: Text('$count'));
  }
}
Enter fullscreen mode Exit fullscreen mode

Assuming the button is pressed once, what prints?

Answer:

A: 1
Build: 1
B: 2
Build: 2
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. setState(() { count++; }) — count becomes 1, widget is marked dirty.
  2. print('A: $count') — prints A: 1.
  3. await Future.delayed(Duration.zero) — yields to the event loop. The pending rebuild runs.
  4. Build: 1 — the framework rebuilds the widget with count = 1.
  5. After the microtask, the code after await resumes.
  6. setState(() { count++; }) — count becomes 2, widget marked dirty again.
  7. print('B: $count') — prints B: 2.
  8. The function returns, event loop processes the rebuild.
  9. Build: 2 — widget rebuilds with count = 2.

Key insight: await Future.delayed(Duration.zero) allows the pending frame to render between the two setStates. Without the await, both setStates would batch into a single rebuild.


Q3: Key Behavior with List Reordering

What's the output?

class ItemWidget extends StatefulWidget {
  final String title;
  const ItemWidget({super.key, required this.title});

  @override
  State<ItemWidget> createState() => _ItemWidgetState();
}

class _ItemWidgetState extends State<ItemWidget> {
  late final String _initTime;

  @override
  void initState() {
    super.initState();
    _initTime = DateTime.now().millisecondsSinceEpoch.toString();
    print('initState: ${widget.title}');
  }

  @override
  Widget build(BuildContext context) {
    return Text('${widget.title} - $_initTime');
  }
}

// Parent widget toggles between:
// State A: [ItemWidget(title: 'Alpha'), ItemWidget(title: 'Beta')]
// State B: [ItemWidget(title: 'Beta'), ItemWidget(title: 'Alpha')]
// (No keys provided)
Enter fullscreen mode Exit fullscreen mode

What happens when switching from State A to State B?

Answer:

// Initial (State A):
initState: Alpha
initState: Beta
// Display: "Alpha - 1000", "Beta - 1001"

// After switch to State B:
// NO initState calls!
// Display: "Beta - 1000", "Alpha - 1001"
Enter fullscreen mode Exit fullscreen mode

The surprise: The _initTime values are swapped from what you would expect. "Beta" shows the old timestamp that was created for "Alpha."

Why: Without keys, Flutter matches widgets by position in the list, not by content. The first slot's Element/State stays put; Flutter just updates the widget property. So:

  • Position 0: State object stays (with its _initTime), but widget.title changes from "Alpha" to "Beta".
  • Position 1: State object stays (with its _initTime), but widget.title changes from "Beta" to "Alpha".
  • initState is NOT called because the State objects are reused.

The fix — add keys:

ItemWidget(key: ValueKey('Alpha'), title: 'Alpha'),
ItemWidget(key: ValueKey('Beta'), title: 'Beta'),
Enter fullscreen mode Exit fullscreen mode

Now Flutter matches by key. When the order changes, it moves the entire Element+State to the correct position. _initTime stays with the correct title.


Q4: Stream Controller — Broadcast vs Single Subscription

What's the output?

void main() async {
  // Single subscription
  final single = StreamController<int>();
  single.stream.listen((e) => print('Single A: $e'));
  // single.stream.listen((e) => print('Single B: $e')); // Would throw!

  single.add(1);
  single.add(2);

  // Broadcast
  final broadcast = StreamController<int>.broadcast();
  broadcast.stream.listen((e) => print('Broadcast A: $e'));
  broadcast.stream.listen((e) => print('Broadcast B: $e'));

  broadcast.add(1);
  broadcast.add(2);

  await Future.delayed(Duration.zero);

  single.close();
  broadcast.close();
}
Enter fullscreen mode Exit fullscreen mode

Answer:

Single A: 1
Single A: 2
Broadcast A: 1
Broadcast B: 1
Broadcast A: 2
Broadcast B: 2
Enter fullscreen mode Exit fullscreen mode

Key differences revealed:

Single Subscription Broadcast
Only ONE listener allowed Multiple listeners
Buffers events until listener attaches Events lost if no listener at emission time
Pausing the listener pauses the stream Pausing one listener does not affect others
Default StreamController() StreamController.broadcast()

The hidden trap — broadcast does NOT buffer:

final broadcast = StreamController<int>.broadcast();
broadcast.add(1); // LOST — no listener yet!
broadcast.stream.listen((e) => print(e)); // Will never see 1
broadcast.add(2); // Prints 2
Enter fullscreen mode Exit fullscreen mode

This is why BehaviorSubject from rxdart exists — it is a broadcast stream that replays the last value to new listeners.


Q5: Provider Update Notification Timing

What's the output?

class Counter with ChangeNotifier {
  int _value = 0;
  int get value => _value;

  void increment() {
    _value++;
    print('Before notify: $_value');
    notifyListeners();
    print('After notify: $_value');
  }
}

// In a widget:
Widget build(BuildContext context) {
  final counter = context.watch<Counter>();
  print('Build: ${counter.value}');
  return ElevatedButton(
    onPressed: () {
      print('Tap start');
      counter.increment();
      counter.increment();
      print('Tap end');
    },
    child: Text('${counter.value}'),
  );
}
Enter fullscreen mode Exit fullscreen mode

When the button is pressed once:

Answer:

Tap start
Before notify: 1
After notify: 1
Before notify: 2
After notify: 2
Tap end
Build: 2
Enter fullscreen mode Exit fullscreen mode

Why only ONE build, not two?
notifyListeners() marks the widget as dirty, but Flutter's framework batches rebuilds. Both increment() calls happen synchronously within a single frame. The framework schedules a rebuild after the current synchronous execution completes. When build finally runs, value is already 2.

This is different from setState in one important way: setState(() { ... }) also marks dirty and batches. But with Provider, the notification mechanism goes through ChangeNotifier_InheritedProviderScopemarkNeedsBuild. The effect is the same: synchronous calls batch into one rebuild.


Q6: Widget Lifecycle Method Ordering

What's the output?

class Parent extends StatefulWidget {
  @override State createState() => _ParentState();
}

class _ParentState extends State<Parent> {
  @override void initState() { super.initState(); print('Parent initState'); }
  @override void didChangeDependencies() { super.didChangeDependencies(); print('Parent didChangeDependencies'); }
  @override void dispose() { print('Parent dispose'); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    print('Parent build');
    return Child();
  }
}

class Child extends StatefulWidget {
  @override State createState() => _ChildState();
}

class _ChildState extends State<Child> {
  @override void initState() { super.initState(); print('Child initState'); }
  @override void didChangeDependencies() { super.didChangeDependencies(); print('Child didChangeDependencies'); }
  @override void dispose() { print('Child dispose'); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    print('Child build');
    return const SizedBox();
  }
}
Enter fullscreen mode Exit fullscreen mode

On first mount, what prints?

Answer:

Parent initState
Parent didChangeDependencies
Parent build
Child initState
Child didChangeDependencies
Child build
Enter fullscreen mode Exit fullscreen mode

On removal from tree (e.g., navigation away):

Child dispose
Parent dispose
Enter fullscreen mode Exit fullscreen mode

Key insights:

  1. initStatedidChangeDependenciesbuild — always this order for each widget.
  2. Parent builds before child initializes — the parent's build method creates the Child widget, which then triggers the child's initState.
  3. Disposal is bottom-up — children dispose before parents. This ensures children can still access inherited data during their disposal.
  4. didChangeDependencies is called immediately after initState because the widget is mounting into the tree and inheriting dependencies for the first time.

Q7: Null Safety Edge Cases

What's the output?

void main() {
  int? a = null;
  int b = a ?? 0;
  print(b);               // ?

  String? name;
  print(name?.length);    // ?
  print(name?.length ?? 0); // ?

  List<int>? list;
  print(list?.isEmpty);   // ?
  list = [];
  print(list?.isEmpty);   // ?

  int? x;
  int? y;
  print(x ?? y ?? 42);   // ?

  String? s = 'hello';
  print(s?.toUpperCase()); // ?
  // Does the ?. here matter since s is non-null?
}
Enter fullscreen mode Exit fullscreen mode

Answer:

0
null
0
null
true
42
HELLO
Enter fullscreen mode Exit fullscreen mode

Explanation:

int b = a ?? 0;           // a is null → 0
print(name?.length);       // name is null → null (short-circuits)
print(name?.length ?? 0);  // name?.length is null → ?? returns 0
print(list?.isEmpty);      // list is null → null
list = [];
print(list?.isEmpty);      // list is [] → true (list is non-null now, but type is still List<int>?)
print(x ?? y ?? 42);       // x is null → y ?? 42 → y is null → 42
print(s?.toUpperCase());   // s is "hello" → "HELLO" (?. is unnecessary but not harmful)
Enter fullscreen mode Exit fullscreen mode

The ?. on a non-null variable: The compiler may warn that ?. is unnecessary on a variable the analyzer knows is non-null. But it is not an error — it still calls the method normally.


Q8: Isolate Message Passing

What's the output?

import 'dart:isolate';

void isolateFunction(SendPort sendPort) {
  print('Isolate: start');
  sendPort.send('Hello from isolate');
  print('Isolate: sent');
}

void main() async {
  print('Main: start');

  final receivePort = ReceivePort();
  await Isolate.spawn(isolateFunction, receivePort.sendPort);

  print('Main: spawned');

  final message = await receivePort.first;
  print('Main: received "$message"');

  print('Main: done');
}
Enter fullscreen mode Exit fullscreen mode

Answer:

The output is non-deterministic in ordering between main and isolate, but a typical run:

Main: start
Main: spawned
Isolate: start
Isolate: sent
Main: received "Hello from isolate"
Main: done
Enter fullscreen mode Exit fullscreen mode

Key insights:

  1. Isolate.spawn returns a Future that completes when the isolate is created, NOT when it finishes executing.
  2. Main: spawned prints before the isolate starts because spawn returns quickly.
  3. The isolate runs on a separate thread. Its print statements interleave with the main isolate.
  4. receivePort.first awaits the first message, suspending main until the isolate sends something.
  5. sendPort.send is asynchronous from the receiver's perspective — the message is queued.
  6. After receivePort.first, the port is automatically closed (.first listens for one message then cancels).

Important: You cannot send arbitrary objects between isolates. Only primitives, lists, maps, SendPort, TransferableTypedData, and objects that can be serialized are supported. You CANNOT send closures, functions, or objects with native resources.


Q9: Animation Controller Value at Different States

What's the output?

class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 2),
      lowerBound: 0.0,
      upperBound: 1.0,
    );

    print('Initial value: ${_controller.value}');
    print('Initial status: ${_controller.status}');

    _controller.forward();
    print('After forward() value: ${_controller.value}');
    print('After forward() status: ${_controller.status}');

    _controller.addStatusListener((status) {
      print('Status changed: $status');
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Answer:

Initial value: 0.0
Initial status: AnimationStatus.dismissed
After forward() value: 0.0
After forward() status: AnimationStatus.forward
Enter fullscreen mode Exit fullscreen mode

And later, after 2 seconds:

Status changed: AnimationStatus.completed
Enter fullscreen mode Exit fullscreen mode

Key insights:

  1. _controller.value is still 0.0 immediately after forward() — the animation has not ticked yet. forward() just schedules the animation to start on the next frame.
  2. _controller.status is forward immediately — the status changes synchronously when you call forward(), even though no frames have ticked.
  3. The status listener is added AFTER forward() — so it will NOT fire for the dismissed → forward transition. It will only fire when the animation completes (forward → completed).
  4. If you want to catch all status changes, add the listener BEFORE calling forward().

Value progression (each frame):

Frame 0: 0.0
Frame 1: ~0.008 (depends on frame rate)
Frame 2: ~0.016
...
Frame 120 (at 60fps × 2s): 1.0
Status: completed
Enter fullscreen mode Exit fullscreen mode

Q10: BuildContext Availability in Callbacks

What's the output?

class MyWidget extends StatefulWidget { ... }

class _MyWidgetState extends State<MyWidget> {
  @override
  void initState() {
    super.initState();
    // Can we use context here?
    print('initState mounted: $mounted');
    // print(Theme.of(context)); // Does this work?
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print('didChangeDependencies mounted: $mounted');
    print('Theme accessible: ${Theme.of(context).brightness}');
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async {
        final navigator = Navigator.of(context);
        await Future.delayed(Duration(seconds: 2));
        // Is context safe here?
        print('After delay mounted: $mounted');
        if (mounted) {
          navigator.push(MaterialPageRoute(builder: (_) => OtherScreen()));
        }
      },
      child: const Text('Navigate'),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Answer:

initState mounted: true
didChangeDependencies mounted: true
Theme accessible: Brightness.light  // (or dark, depending on theme)
Enter fullscreen mode Exit fullscreen mode

When button is pressed and after 2 seconds:

After delay mounted: true  // (or false if navigated away)
Enter fullscreen mode Exit fullscreen mode

Key rules about context availability:

Lifecycle Method context safe? Notes
initState Technically yes, but don't use of(context) context exists but InheritedWidget lookups are unreliable. The widget is not yet fully inserted into the tree. Framework will warn.
didChangeDependencies Yes — this is the right place First safe place to call Theme.of(context), MediaQuery.of(context), etc.
build Yes Standard usage.
dispose No for ancestor lookups Widget is being removed. Ancestors may be gone.
async callbacks Maybe — check mounted The widget may have been disposed during the await.

The initState + context trap:

@override
void initState() {
  super.initState();
  // This "works" but is WRONG:
  // final theme = Theme.of(context); // Triggers dependOnInheritedWidgetOfExactType during initState

  // Do this instead:
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final theme = Theme.of(context); // Safe — first frame has completed
  });
}

// Or use didChangeDependencies:
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  final theme = Theme.of(context); // Correct place
}
Enter fullscreen mode Exit fullscreen mode

Q11 (Bonus): What does this print?

void main() async {
  print('1');
  Future(() => print('2'));
  Future.microtask(() => print('3'));
  await Future.delayed(Duration.zero, () => print('4'));
  print('5');
  Future(() => print('6'));
  Future.microtask(() => print('7'));
}
Enter fullscreen mode Exit fullscreen mode

Answer:

1
3
2
4
5
7
6
Enter fullscreen mode Exit fullscreen mode

Explanation (Dart event loop):

  1. print('1') — synchronous, prints immediately.
  2. Future(() => print('2')) — schedules print('2') on the event queue (low priority).
  3. Future.microtask(() => print('3')) — schedules print('3') on the microtask queue (high priority).
  4. await Future.delayed(Duration.zero, ...)await yields to the event loop. Before processing the event queue, Dart drains the microtask queue:
    • Microtask: print('3') → prints 3.
    • Event queue: print('2') → prints 2.
    • Then Future.delayed(Duration.zero) resolves: prints 4.
  5. await resumes. print('5') — prints 5.
  6. Future(() => print('6')) — schedules on event queue.
  7. Future.microtask(() => print('7')) — schedules on microtask queue.
  8. main returns. Dart drains microtask queue first: prints 7. Then event queue: prints 6.

The rule: Microtasks always run before the next event. await yields to both queues.


Q12 (Bonus): What happens here?

void main() {
  try {
    Future.error('async error');
    print('No crash');
  } catch (e) {
    print('Caught: $e');
  }
}
Enter fullscreen mode Exit fullscreen mode

Answer:

No crash
Enter fullscreen mode Exit fullscreen mode

Then, after the synchronous code completes, the Dart runtime reports an unhandled async error (which in Flutter shows a red screen or logs to console).

Why? try-catch only catches synchronous exceptions. Future.error() creates a Future that completes with an error — this error lives in the async world. The catch block never sees it because no synchronous exception was thrown.

To catch it:

// Option 1: await the Future
try {
  await Future.error('async error');
} catch (e) {
  print('Caught: $e'); // Now it works
}

// Option 2: Use .catchError
Future.error('async error').catchError((e) => print('Caught: $e'));

// Option 3: Zone-level error handling
runZonedGuarded(() {
  Future.error('async error');
}, (error, stack) {
  print('Zone caught: $error');
});
Enter fullscreen mode Exit fullscreen mode

Top comments (0)