DEV Community

Cover image for Flutter Interview Questions Part 13: Real-World Scenarios & System Design
Anurag Dubey
Anurag Dubey

Posted on

Flutter Interview Questions Part 13: Real-World Scenarios & System Design

Welcome to Part 13 of the Flutter Interview Questions series! This part is all about the questions that separate textbook knowledge from production experience. We start with "How would you implement..." scenarios covering infinite scrolling with pagination, payment flows (Razorpay/Stripe), chat UI with typing indicators and read receipts, biometric authentication, and offline-first CRUD with background sync. Then we move into system design territory: architecting a food delivery app like Swiggy/Zomato, designing a social media feed with stories and messaging, building scalable notification systems, and implementing multi-level caching layers. This is part 13 of our comprehensive 14-part series -- bookmark it and come back whenever you need to prepare for senior-level Flutter interviews.

What's in this part?

  • Section 1: Real-World Scenario-Based Questions (15 Qs) -- Infinite scrolling with pagination, real-time search with debouncing, image pick/crop/compress/upload pipeline, payment flows with proper error handling, multiple theme support, chat UI with typing indicators, biometric authentication, video player with PiP, maps with clustering, offline-first CRUD, complex form validation, multi-step onboarding, local push notifications, dynamic feature modules, persistent settings
  • Section 1: System Design for Mobile (10 Qs) -- Food delivery app architecture, social media app with feed/stories/messaging, scalable notification system, multi-level caching layer, multi-language RTL support, error handling and logging strategy, CI/CD pipeline, analytics system, e-commerce state management, plugin architecture

SECTION 1: REAL-WORLD SCENARIO-BASED QUESTIONS

These "How would you implement..." questions test whether a candidate can translate architectural knowledge into production-quality solutions. Textbook answers will not survive the follow-ups.


Q1. How would you implement infinite scrolling with pagination and pull-to-refresh?

What the interviewer is REALLY testing:
Whether you understand lazy loading, scroll controller thresholds, state deduplication, race conditions between pull-to-refresh and pagination, and proper loading/error/empty states.

Answer:

The core mechanism is a ScrollController with a listener that triggers a fetch when the user scrolls near the bottom, combined with RefreshIndicator for pull-to-refresh. But the devil is in the details.

Key architectural decisions:

  1. Pagination state must track: current page (or cursor), whether more data exists, whether a fetch is in-flight (to prevent duplicate requests), and any error state.
  2. Pull-to-refresh must reset pagination -- not append to existing data.
  3. Race condition: If a user pulls to refresh while a page-3 fetch is in flight, the page-3 response arrives after the refresh response and corrupts the list. You need request cancellation or generation counters.
class PaginatedListNotifier extends ChangeNotifier {
  List<Item> _items = [];
  int _page = 1;
  bool _hasMore = true;
  bool _isLoading = false;
  Object? _error;
  int _generation = 0; // race condition guard

  List<Item> get items => _items;
  bool get hasMore => _hasMore;
  bool get isLoading => _isLoading;
  Object? get error => _error;

  Future<void> fetchNextPage() async {
    if (_isLoading || !_hasMore) return; // deduplicate
    _isLoading = true;
    _error = null;
    notifyListeners();

    final gen = _generation; // capture current generation
    try {
      final newItems = await _api.getItems(page: _page, limit: 20);
      if (gen != _generation) return; // stale response, discard
      _items = [..._items, ...newItems];
      _page++;
      _hasMore = newItems.length == 20;
    } catch (e) {
      if (gen != _generation) return;
      _error = e;
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<void> refresh() async {
    _generation++; // invalidate any in-flight requests
    _page = 1;
    _hasMore = true;
    _items = [];
    _error = null;
    _isLoading = false;
    notifyListeners();
    await fetchNextPage();
  }
}
Enter fullscreen mode Exit fullscreen mode
class PaginatedListView extends StatefulWidget {
  @override
  State<PaginatedListView> createState() => _PaginatedListViewState();
}

class _PaginatedListViewState extends State<PaginatedListView> {
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
    context.read<PaginatedListNotifier>().fetchNextPage();
  }

  void _onScroll() {
    // Trigger fetch when within 200px of the bottom
    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 200) {
      context.read<PaginatedListNotifier>().fetchNextPage();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Consumer<PaginatedListNotifier>(
      builder: (context, notifier, _) {
        return RefreshIndicator(
          onRefresh: notifier.refresh,
          child: ListView.builder(
            controller: _scrollController,
            // +1 for the loading/error indicator at the bottom
            itemCount: notifier.items.length + (notifier.hasMore ? 1 : 0),
            itemBuilder: (context, index) {
              if (index == notifier.items.length) {
                // Bottom loader or error widget
                if (notifier.error != null) {
                  return _RetryTile(onRetry: notifier.fetchNextPage);
                }
                return const Center(child: CircularProgressIndicator());
              }
              return ItemTile(item: notifier.items[index]);
            },
          ),
        );
      },
    );
  }

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

Follow-up traps an interviewer might set:

  • "What if items can be deleted/updated while paginating?" -- You need unique IDs and deduplication logic, not just appending blindly.
  • "What about cursor-based vs offset-based pagination?" -- Offset-based breaks when items are inserted/deleted between pages. Cursor-based (pass the last item's ID/timestamp) is more robust.
  • "How do you preserve scroll position on orientation change?" -- PageStorageKey or manual offset restoration.

Q2. How would you implement a real-time search with debouncing that cancels previous requests?

What the interviewer is REALLY testing:
Whether you understand debouncing vs throttling, CancelableOperation or Timer cancellation, how to avoid showing stale results, and the UX considerations (loading indicators, empty states, minimum query length).

Answer:

The naive approach uses a Timer. The production approach uses CancelableOperation from package:async or Dio's CancelToken to actually abort HTTP requests, not just ignore their results.

class SearchNotifier extends ChangeNotifier {
  final ApiService _api;
  Timer? _debounceTimer;
  CancelableOperation<List<SearchResult>>? _pendingSearch;

  List<SearchResult> _results = [];
  bool _isSearching = false;
  String _lastQuery = '';

  List<SearchResult> get results => _results;
  bool get isSearching => _isSearching;

  void onQueryChanged(String query) {
    _debounceTimer?.cancel();

    if (query.trim().length < 2) {
      // Don't search for very short queries
      _pendingSearch?.cancel();
      _results = [];
      _isSearching = false;
      notifyListeners();
      return;
    }

    _debounceTimer = Timer(const Duration(milliseconds: 400), () {
      _performSearch(query.trim());
    });
  }

  Future<void> _performSearch(String query) async {
    if (query == _lastQuery) return; // same query, skip
    _lastQuery = query;

    // Cancel the previous in-flight request
    await _pendingSearch?.cancel();

    _isSearching = true;
    notifyListeners();

    _pendingSearch = CancelableOperation<List<SearchResult>>.fromFuture(
      _api.search(query),
    );

    try {
      final results = await _pendingSearch!.value;
      // Only update if this operation wasn't cancelled
      if (!_pendingSearch!.isCanceled) {
        _results = results;
      }
    } catch (e) {
      if (!_pendingSearch!.isCanceled) {
        _results = [];
      }
    } finally {
      _isSearching = false;
      notifyListeners();
    }
  }

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

With Dio's CancelToken for actual HTTP cancellation:

Future<void> _performSearch(String query) async {
  _cancelToken?.cancel(); // cancel actual HTTP request
  _cancelToken = CancelToken();

  _isSearching = true;
  notifyListeners();

  try {
    final response = await _dio.get(
      '/search',
      queryParameters: {'q': query},
      cancelToken: _cancelToken,
    );
    _results = (response.data as List).map((e) => SearchResult.fromJson(e)).toList();
  } on DioException catch (e) {
    if (e.type != DioExceptionType.cancel) {
      _results = []; // real error, clear results
    }
    // If cancelled, do nothing -- a newer search is handling the UI
  } finally {
    _isSearching = false;
    notifyListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

Why actual HTTP cancellation matters: Without CancelToken, the server still processes all requests. If a user types "flutter widgets" character by character, the server handles "fl", "flu", "flut", "flutt", "flutte", "flutter", "flutter ", "flutter w" ... Most of these are wasted. With CancelToken, Dio closes the socket and the server can detect the disconnection.

RxDart alternative (concise but adds a dependency):

final _querySubject = BehaviorSubject<String>();

Stream<List<SearchResult>> get searchResults => _querySubject.stream
    .debounceTime(const Duration(milliseconds: 400))
    .where((q) => q.trim().length >= 2)
    .distinct()
    .switchMap((query) => _api.searchStream(query)); // switchMap auto-cancels previous
Enter fullscreen mode Exit fullscreen mode

Q3. How would you implement an image picker with crop, compress, and upload with progress?

What the interviewer is REALLY testing:
Whether you understand platform channels, file handling, compute-heavy operations (should cropping/compression run on an isolate?), streaming upload with progress tracking, and error handling at every step of the pipeline.

Answer:

This is a pipeline problem: pick --> crop --> compress --> upload (with progress). Each step can fail, and the user needs feedback throughout.

Packages: image_picker, image_cropper, flutter_image_compress, dio (for upload progress).

class ImageUploadService {
  final Dio _dio;

  /// Returns the remote URL of the uploaded image, or throws.
  Future<String> pickCropCompressUpload({
    required ImageSource source,
    required ValueChanged<double> onProgress,
    int maxWidth = 1080,
    int quality = 75,
  }) async {
    // STEP 1: Pick
    final picker = ImagePicker();
    final XFile? picked = await picker.pickImage(
      source: source,
      maxWidth: 2000, // rough cap before crop
    );
    if (picked == null) throw ImageFlowCancelledException('User cancelled picker');

    // STEP 2: Crop
    final CroppedFile? cropped = await ImageCropper().cropImage(
      sourcePath: picked.path,
      aspectRatio: const CropAspectRatio(ratioX: 1, ratioY: 1),
      uiSettings: [
        AndroidUiSettings(
          toolbarTitle: 'Crop Image',
          lockAspectRatio: false,
        ),
        IOSUiSettings(title: 'Crop Image'),
      ],
    );
    if (cropped == null) throw ImageFlowCancelledException('User cancelled crop');

    // STEP 3: Compress (runs native code, not Dart isolate needed)
    final Uint8List? compressed = await FlutterImageCompress.compressWithFile(
      cropped.path,
      minWidth: maxWidth,
      minHeight: maxWidth,
      quality: quality,
      format: CompressFormat.jpeg,
    );
    if (compressed == null) throw ImageFlowException('Compression failed');

    // Log size reduction
    final originalSize = await File(cropped.path).length();
    debugPrint('Compressed: ${originalSize} -> ${compressed.length} bytes '
        '(${(compressed.length / originalSize * 100).toStringAsFixed(1)}%)');

    // STEP 4: Upload with progress
    final formData = FormData.fromMap({
      'file': MultipartFile.fromBytes(
        compressed,
        filename: 'upload_${DateTime.now().millisecondsSinceEpoch}.jpg',
        contentType: MediaType('image', 'jpeg'),
      ),
    });

    final response = await _dio.post(
      '/api/upload',
      data: formData,
      onSendProgress: (sent, total) {
        onProgress(sent / total);
      },
    );

    return response.data['url'] as String;
  }
}
Enter fullscreen mode Exit fullscreen mode

UI integration:

class _UploadButtonState extends State<UploadButton> {
  double _progress = 0;
  UploadState _state = UploadState.idle;

  Future<void> _startUpload(ImageSource source) async {
    setState(() => _state = UploadState.processing);
    try {
      final url = await _service.pickCropCompressUpload(
        source: source,
        onProgress: (p) => setState(() {
          _progress = p;
          _state = UploadState.uploading;
        }),
      );
      setState(() => _state = UploadState.done);
      widget.onUploaded(url);
    } on ImageFlowCancelledException {
      setState(() => _state = UploadState.idle); // user cancelled, silent
    } catch (e) {
      setState(() => _state = UploadState.error);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Upload failed: $e')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return switch (_state) {
      UploadState.idle => ElevatedButton(
          onPressed: () => _showSourcePicker(),
          child: const Text('Upload Image'),
        ),
      UploadState.processing => const Row(
          children: [CircularProgressIndicator(), Text(' Processing...')],
        ),
      UploadState.uploading => LinearProgressIndicator(value: _progress),
      UploadState.done => const Icon(Icons.check, color: Colors.green),
      UploadState.error => TextButton(
          onPressed: () => _showSourcePicker(),
          child: const Text('Retry'),
        ),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Critical details interviewers look for:

  • Compression should happen AFTER cropping (don't compress pixels that will be thrown away).
  • File cleanup: delete temporary files after upload to avoid filling device storage.
  • Permission handling: camera and photo library permissions must be requested before picking.
  • On iOS, NSPhotoLibraryUsageDescription and NSCameraUsageDescription must be in Info.plist.

Q4. How would you implement a payment flow (Razorpay/Stripe) with proper error handling?

What the interviewer is REALLY testing:
Whether you understand the client-server split in payment flows (NEVER create payment intents on the client), idempotency keys, handling all failure modes (network failure mid-payment, app killed during payment, double charges), and webhook-based confirmation vs client-side confirmation.

Answer:

The golden rule: The server is the source of truth for payment state, never the client.

The correct flow:

Client                        Server                     Payment Gateway
  |                             |                              |
  |--- 1. Create Order -------->|                              |
  |                             |--- 2. Create PaymentIntent ->|
  |<-- 3. Return client_secret--|<-- Return PI -------- -------|
  |                             |                              |
  |--- 4. Collect card & confirm (SDK handles) --------------->|
  |<-- 5. Payment result (success/failure/pending) ------------|
  |                             |                              |
  |--- 6. Verify with server -->|                              |
  |                             |--- 7. Verify via API ------->|
  |                             |<-- Confirmed/Denied ---------|
  |<-- 8. Order confirmed ------|                              |
Enter fullscreen mode Exit fullscreen mode
class PaymentService {
  final Dio _dio;

  /// Step 1-3: Create order on server, get client secret
  Future<PaymentSession> createPaymentSession({
    required String orderId,
    required int amountInPaise,
    required String currency,
    required String idempotencyKey, // prevents double charges on retry
  }) async {
    final response = await _dio.post(
      '/api/payments/create-session',
      data: {
        'order_id': orderId,
        'amount': amountInPaise,
        'currency': currency,
      },
      options: Options(headers: {
        'Idempotency-Key': idempotencyKey,
      }),
    );
    return PaymentSession.fromJson(response.data);
  }

  /// Step 6-8: Verify payment on server (never trust client-side result alone)
  Future<PaymentVerification> verifyPayment({
    required String paymentIntentId,
    required String orderId,
  }) async {
    final response = await _dio.post(
      '/api/payments/verify',
      data: {
        'payment_intent_id': paymentIntentId,
        'order_id': orderId,
      },
    );
    return PaymentVerification.fromJson(response.data);
  }
}
Enter fullscreen mode Exit fullscreen mode

Razorpay implementation:

class RazorpayPaymentHandler {
  late final Razorpay _razorpay;
  Completer<PaymentResult>? _paymentCompleter;

  RazorpayPaymentHandler() {
    _razorpay = Razorpay();
    _razorpay.on(Razorpay.EVENT_PAYMENT_SUCCESS, _onSuccess);
    _razorpay.on(Razorpay.EVENT_PAYMENT_ERROR, _onError);
    _razorpay.on(Razorpay.EVENT_EXTERNAL_WALLET, _onExternalWallet);
  }

  Future<PaymentResult> startPayment({
    required PaymentSession session,
    required String customerPhone,
    required String customerEmail,
  }) async {
    _paymentCompleter = Completer<PaymentResult>();

    final options = {
      'key': session.razorpayKeyId,
      'amount': session.amountInPaise,
      'currency': session.currency,
      'order_id': session.gatewayOrderId,
      'name': 'My App',
      'prefill': {
        'contact': customerPhone,
        'email': customerEmail,
      },
      'retry': {'enabled': true, 'max_count': 1},
      'timeout': 300, // 5 minutes
    };

    _razorpay.open(options);
    return _paymentCompleter!.future;
  }

  void _onSuccess(PaymentSuccessResponse response) {
    _paymentCompleter?.complete(PaymentResult.success(
      paymentId: response.paymentId!,
      orderId: response.orderId!,
      signature: response.signature!,
    ));
  }

  void _onError(PaymentFailureResponse response) {
    _paymentCompleter?.complete(PaymentResult.failure(
      code: response.code ?? -1,
      message: response.message ?? 'Unknown error',
    ));
  }

  void _onExternalWallet(ExternalWalletResponse response) {
    _paymentCompleter?.complete(PaymentResult.externalWallet(
      walletName: response.walletName ?? 'Unknown',
    ));
  }

  void dispose() {
    _razorpay.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

The critical error handling that separates juniors from seniors:

Future<void> executePaymentFlow(String orderId, int amount) async {
  final idempotencyKey = const Uuid().v4(); // generate ONCE per user action

  try {
    // Step 1: Create session
    final session = await _paymentService.createPaymentSession(
      orderId: orderId,
      amountInPaise: amount,
      currency: 'INR',
      idempotencyKey: idempotencyKey,
    );

    // Step 2: Collect payment via SDK
    final result = await _razorpayHandler.startPayment(
      session: session,
      customerPhone: _user.phone,
      customerEmail: _user.email,
    );

    if (result.isSuccess) {
      // Step 3: ALWAYS verify server-side
      try {
        final verification = await _paymentService.verifyPayment(
          paymentIntentId: result.paymentId,
          orderId: orderId,
        );
        if (verification.isVerified) {
          _navigateToSuccess(orderId);
        } else {
          // Payment went through but signature mismatch -- potential fraud
          _navigateToContactSupport(orderId, result.paymentId);
        }
      } catch (e) {
        // Network failed AFTER payment succeeded
        // DON'T show "payment failed" -- the money was charged!
        // Show "verifying payment" and retry, or let server webhooks handle it
        _navigateToPendingVerification(orderId, result.paymentId);
      }
    } else {
      _showPaymentFailed(result.message);
    }
  } on DioException catch (e) {
    // Network failed before payment started -- safe to retry
    _showRetryDialog(orderId, amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

The catastrophic mistake: Showing "Payment Failed" when the verification call fails after a successful payment. The user was charged but thinks it failed, tries again, gets double-charged.


Q5. How would you handle multiple themes (light, dark, custom branded themes)?

What the interviewer is REALLY testing:
Whether you understand ThemeData deeply, ThemeExtension for custom tokens, ColorScheme vs individual color properties, dynamic theme switching with state management, and persisting theme preference.

Answer:

The modern approach (Flutter 3.x) centers on ColorScheme and ThemeExtension, not on manually setting individual widget colors.

Step 1: Define custom theme extensions for tokens not covered by Material:

class BrandColors extends ThemeExtension<BrandColors> {
  final Color accentGradientStart;
  final Color accentGradientEnd;
  final Color cardHighlight;
  final Color revenuePositive;
  final Color revenueNegative;

  const BrandColors({
    required this.accentGradientStart,
    required this.accentGradientEnd,
    required this.cardHighlight,
    required this.revenuePositive,
    required this.revenueNegative,
  });

  @override
  BrandColors copyWith({/* ... */}) => BrandColors(/* ... */);

  @override
  BrandColors lerp(BrandColors? other, double t) {
    if (other == null) return this;
    return BrandColors(
      accentGradientStart: Color.lerp(accentGradientStart, other.accentGradientStart, t)!,
      accentGradientEnd: Color.lerp(accentGradientEnd, other.accentGradientEnd, t)!,
      cardHighlight: Color.lerp(cardHighlight, other.cardHighlight, t)!,
      revenuePositive: Color.lerp(revenuePositive, other.revenuePositive, t)!,
      revenueNegative: Color.lerp(revenueNegative, other.revenueNegative, t)!,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Build theme data from a config:

class AppThemeFactory {
  static ThemeData build(AppThemeConfig config) {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: config.seedColor,
      brightness: config.brightness,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      // Override specific components
      appBarTheme: AppBarTheme(
        centerTitle: config.centerTitles,
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
      ),
      cardTheme: CardTheme(
        elevation: config.cardElevation,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(config.borderRadius),
        ),
      ),
      // Attach custom extensions
      extensions: [config.brandColors],
    );
  }
}

// Theme configs
class AppThemeConfig {
  final Color seedColor;
  final Brightness brightness;
  final BrandColors brandColors;
  final double cardElevation;
  final double borderRadius;
  final bool centerTitles;

  const AppThemeConfig({/* ... */});

  static const defaultLight = AppThemeConfig(
    seedColor: Color(0xFF6750A4),
    brightness: Brightness.light,
    brandColors: BrandColors(
      accentGradientStart: Color(0xFF6750A4),
      accentGradientEnd: Color(0xFF9C89D1),
      cardHighlight: Color(0xFFFFF3E0),
      revenuePositive: Color(0xFF2E7D32),
      revenueNegative: Color(0xFFC62828),
    ),
    cardElevation: 1,
    borderRadius: 12,
    centerTitles: true,
  );

  static const defaultDark = AppThemeConfig(
    seedColor: Color(0xFF6750A4),
    brightness: Brightness.dark,
    brandColors: BrandColors(/* dark variants */),
    cardElevation: 0,
    borderRadius: 12,
    centerTitles: true,
  );

  // White-label brand theme
  static const clientAcmeCorp = AppThemeConfig(
    seedColor: Color(0xFFFF6B00), // ACME's brand orange
    brightness: Brightness.light,
    brandColors: BrandColors(/* ACME-specific colors */),
    cardElevation: 2,
    borderRadius: 8,
    centerTitles: false,
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Theme notifier with persistence:

class ThemeNotifier extends ChangeNotifier {
  final SharedPreferences _prefs;
  late AppThemeConfig _config;

  ThemeNotifier(this._prefs) {
    final saved = _prefs.getString('theme_mode');
    _config = switch (saved) {
      'dark' => AppThemeConfig.defaultDark,
      'acme' => AppThemeConfig.clientAcmeCorp,
      _ => AppThemeConfig.defaultLight,
    };
  }

  ThemeData get themeData => AppThemeFactory.build(_config);

  void setTheme(String key) {
    _config = switch (key) {
      'dark' => AppThemeConfig.defaultDark,
      'acme' => AppThemeConfig.clientAcmeCorp,
      _ => AppThemeConfig.defaultLight,
    };
    _prefs.setString('theme_mode', key);
    notifyListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

Accessing custom colors in widgets:

Widget build(BuildContext context) {
  final brand = Theme.of(context).extension<BrandColors>()!;
  return Container(
    decoration: BoxDecoration(
      gradient: LinearGradient(
        colors: [brand.accentGradientStart, brand.accentGradientEnd],
      ),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Why ThemeExtension.lerp matters: When you use AnimatedTheme or theme transitions, Flutter interpolates between themes. Without a proper lerp implementation, custom colors would snap instead of smoothly transitioning.


Q6. How would you implement a chat UI with typing indicators, read receipts, and real-time messages?

What the interviewer is REALLY testing:
Whether you understand WebSocket/real-time connection management, optimistic UI updates, message ordering and deduplication, efficient list rendering for chat (reversed ListView), and the complexity of typing indicators (throttling, timeout).

Answer:

Architecture overview:

┌─────────────────────────────────────────────────────┐
│  ChatScreen (UI)                                     │
│  - Reversed ListView.builder for messages            │
│  - TextInput with typing detection                   │
├─────────────────────────────────────────────────────┤
│  ChatBloc / ChatNotifier (State Management)          │
│  - Message list, typing users, read status           │
├─────────────────────────────────────────────────────┤
│  ChatRepository                                      │
│  - Merges local DB + real-time stream                │
├──────────────────────┬──────────────────────────────┤
│  WebSocketService    │   LocalDatabase (Drift/Isar)  │
│  - Connection mgmt   │   - Message persistence       │
│  - Reconnection      │   - Offline queue             │
│  - Heartbeat         │                               │
└──────────────────────┴──────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The reversed ListView trick for chat:

ListView.builder(
  reverse: true, // newest messages at the bottom, scroll starts at bottom
  controller: _scrollController,
  itemCount: messages.length,
  itemBuilder: (context, index) {
    final message = messages[index]; // messages sorted newest-first
    final previousMessage = index + 1 < messages.length ? messages[index + 1] : null;

    return MessageBubble(
      message: message,
      showAvatar: _shouldShowAvatar(message, previousMessage),
      showTimestamp: _shouldShowTimestamp(message, previousMessage),
    );
  },
)
Enter fullscreen mode Exit fullscreen mode

Why reversed? A normal ListView puts item 0 at the top. In chat, the newest message should be at the bottom and visible. With reverse: true, item 0 renders at the bottom. When you prepend new messages to the list, they appear at the bottom without scroll jumps.

Typing indicator with throttling:

class TypingIndicatorManager {
  final WebSocketService _ws;
  Timer? _stopTypingTimer;
  bool _isTypingLocally = false;

  /// Call this on every keystroke in the text field
  void onTextChanged(String text) {
    if (text.isNotEmpty && !_isTypingLocally) {
      _isTypingLocally = true;
      _ws.send({'type': 'typing_start', 'chat_id': _chatId});
    }

    // Reset the "stop typing" timer on every keystroke
    _stopTypingTimer?.cancel();
    _stopTypingTimer = Timer(const Duration(seconds: 3), () {
      _isTypingLocally = false;
      _ws.send({'type': 'typing_stop', 'chat_id': _chatId});
    });

    if (text.isEmpty) {
      _stopTypingTimer?.cancel();
      _isTypingLocally = false;
      _ws.send({'type': 'typing_stop', 'chat_id': _chatId});
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Remote typing indicators (handling timeouts):

class RemoteTypingTracker {
  final Map<String, Timer> _typingTimers = {};
  final _typingUsers = ValueNotifier<Set<String>>({});

  ValueListenable<Set<String>> get typingUsers => _typingUsers;

  void onRemoteTypingEvent(String userId, bool isTyping) {
    _typingTimers[userId]?.cancel();

    if (isTyping) {
      _typingUsers.value = {..._typingUsers.value, userId};
      // Auto-expire after 5s if no stop event received (user disconnected)
      _typingTimers[userId] = Timer(const Duration(seconds: 5), () {
        _typingUsers.value = {..._typingUsers.value}..remove(userId);
      });
    } else {
      _typingUsers.value = {..._typingUsers.value}..remove(userId);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Read receipts with optimistic updates:

void sendMessage(String text) {
  final localMessage = Message(
    id: const Uuid().v4(),
    text: text,
    senderId: _currentUserId,
    timestamp: DateTime.now(),
    status: MessageStatus.sending, // optimistic
  );

  // Show immediately in UI
  _messages.insert(0, localMessage);
  notifyListeners();

  // Persist locally
  _localDb.insertMessage(localMessage);

  // Send via WebSocket
  _ws.send({
    'type': 'message',
    'payload': localMessage.toJson(),
  });
}

void _onServerAck(String messageId) {
  _updateMessageStatus(messageId, MessageStatus.sent);
}

void _onDeliveredReceipt(String messageId) {
  _updateMessageStatus(messageId, MessageStatus.delivered);
}

void _onReadReceipt(String messageId) {
  _updateMessageStatus(messageId, MessageStatus.read);
}
Enter fullscreen mode Exit fullscreen mode

Q7. How would you implement biometric authentication (fingerprint/face ID)?

What the interviewer is REALLY testing:
Whether you understand that biometrics are LOCAL authentication only (they don't replace server auth), the fallback strategy when biometrics are unavailable, and the security implications (biometrics unlock a stored credential, they don't replace it).

Answer:

The critical misunderstanding to avoid: Biometric auth does NOT send your fingerprint to a server. It unlocks locally stored credentials (like a token or PIN) which then authenticate with the server.

Flow:

  1. User logs in with username/password, server returns an auth token.
  2. App asks "Enable biometric login?" If yes, store the token in secure storage (Keychain on iOS, Keystore on Android).
  3. On next app launch, prompt biometric. If it succeeds, retrieve the stored token and use it for API calls.
class BiometricAuthService {
  final LocalAuthentication _localAuth = LocalAuthentication();
  final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();

  /// Check if biometric auth is available on this device
  Future<BiometricCapability> checkCapability() async {
    final canCheck = await _localAuth.canCheckBiometrics;
    final isDeviceSupported = await _localAuth.isDeviceSupported();

    if (!canCheck || !isDeviceSupported) {
      return BiometricCapability.notAvailable;
    }

    final availableBiometrics = await _localAuth.getAvailableBiometrics();
    if (availableBiometrics.isEmpty) {
      return BiometricCapability.notEnrolled; // hardware exists but no fingerprint/face set up
    }

    return BiometricCapability.available;
  }

  /// Store credentials after successful login
  Future<void> enableBiometricLogin(String authToken, String refreshToken) async {
    await _secureStorage.write(key: 'auth_token', value: authToken);
    await _secureStorage.write(key: 'refresh_token', value: refreshToken);
    await _secureStorage.write(key: 'biometric_enabled', value: 'true');
  }

  /// Authenticate and retrieve stored credentials
  Future<StoredCredentials?> authenticateAndRetrieve() async {
    final enabled = await _secureStorage.read(key: 'biometric_enabled');
    if (enabled != 'true') return null;

    final didAuthenticate = await _localAuth.authenticate(
      localizedReason: 'Verify your identity to sign in',
      options: const AuthenticationOptions(
        stickyAuth: true,     // don't cancel if app goes to background briefly
        biometricOnly: false, // allow PIN/pattern as fallback
      ),
    );

    if (!didAuthenticate) return null;

    final authToken = await _secureStorage.read(key: 'auth_token');
    final refreshToken = await _secureStorage.read(key: 'refresh_token');

    if (authToken == null || refreshToken == null) {
      // Tokens were cleared (logout, or storage corrupted)
      await disableBiometricLogin();
      return null;
    }

    return StoredCredentials(authToken: authToken, refreshToken: refreshToken);
  }

  Future<void> disableBiometricLogin() async {
    await _secureStorage.delete(key: 'auth_token');
    await _secureStorage.delete(key: 'refresh_token');
    await _secureStorage.write(key: 'biometric_enabled', value: 'false');
  }
}
Enter fullscreen mode Exit fullscreen mode

Platform-specific configuration:

Android MainActivity.kt:

// Use FragmentActivity, not FlutterActivity
class MainActivity: FlutterFragmentActivity()
Enter fullscreen mode Exit fullscreen mode

Android AndroidManifest.xml:

<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
Enter fullscreen mode Exit fullscreen mode

iOS Info.plist:

<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to securely sign you in</string>
Enter fullscreen mode Exit fullscreen mode

Security considerations:

  • On Android, FlutterSecureStorage uses EncryptedSharedPreferences backed by the Android Keystore. The keys are hardware-backed on devices with a Trusted Execution Environment.
  • On iOS, it uses the Keychain, which is hardware-encrypted.
  • The stickyAuth: true option prevents the auth dialog from being dismissed if the user switches apps briefly -- without it, a background/foreground cycle cancels the prompt.
  • Always provide a non-biometric fallback (device PIN/pattern) for accessibility.

Q8. How would you build a video player with custom controls and picture-in-picture support?

What the interviewer is REALLY testing:
Whether you understand platform views vs texture-based rendering, lifecycle management (pause on background, resume on foreground), custom overlay controls with animated opacity, and PiP as a platform-specific feature requiring method channels.

Answer:

class CustomVideoPlayer extends StatefulWidget {
  final String videoUrl;
  const CustomVideoPlayer({required this.videoUrl});

  @override
  State<CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}

class _CustomVideoPlayerState extends State<CustomVideoPlayer>
    with WidgetsBindingObserver {
  late final VideoPlayerController _controller;
  bool _showControls = true;
  Timer? _hideControlsTimer;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl))
      ..initialize().then((_) => setState(() {}))
      ..setLooping(false)
      ..addListener(_onVideoUpdate);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // Pause when app goes to background (unless PiP is active)
    if (state == AppLifecycleState.paused && !_isPipActive) {
      _controller.pause();
    }
  }

  void _onVideoUpdate() {
    // Update UI for position changes, buffering state, etc.
    if (mounted) setState(() {});
  }

  void _toggleControls() {
    setState(() => _showControls = !_showControls);
    _hideControlsTimer?.cancel();
    if (_showControls) {
      _hideControlsTimer = Timer(const Duration(seconds: 3), () {
        if (mounted && _controller.value.isPlaying) {
          setState(() => _showControls = false);
        }
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (!_controller.value.isInitialized) {
      return const AspectRatio(
        aspectRatio: 16 / 9,
        child: Center(child: CircularProgressIndicator()),
      );
    }

    return AspectRatio(
      aspectRatio: _controller.value.aspectRatio,
      child: GestureDetector(
        onTap: _toggleControls,
        // Double-tap left/right to seek
        onDoubleTapDown: (details) {
          final screenWidth = MediaQuery.of(context).size.width;
          if (details.globalPosition.dx < screenWidth / 2) {
            _seekRelative(const Duration(seconds: -10));
          } else {
            _seekRelative(const Duration(seconds: 10));
          }
        },
        child: Stack(
          children: [
            VideoPlayer(_controller),

            // Buffering indicator
            if (_controller.value.isBuffering)
              const Center(child: CircularProgressIndicator(color: Colors.white)),

            // Custom controls overlay
            AnimatedOpacity(
              opacity: _showControls ? 1.0 : 0.0,
              duration: const Duration(milliseconds: 300),
              child: _ControlsOverlay(
                controller: _controller,
                onPlayPause: _togglePlayPause,
                onSeek: (position) => _controller.seekTo(position),
                onPip: _enterPip,
                onFullscreen: _toggleFullscreen,
              ),
            ),
          ],
        ),
      ),
    );
  }

  void _seekRelative(Duration offset) {
    final newPosition = _controller.value.position + offset;
    _controller.seekTo(
      newPosition.isNegative ? Duration.zero : newPosition,
    );
  }

  void _togglePlayPause() {
    _controller.value.isPlaying ? _controller.pause() : _controller.play();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _hideControlsTimer?.cancel();
    _controller.dispose();
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom progress bar with buffered indicator:

class _VideoProgressBar extends StatelessWidget {
  final VideoPlayerController controller;
  final ValueChanged<Duration> onSeek;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final duration = controller.value.duration;
        final position = controller.value.position;
        final buffered = controller.value.buffered;

        return GestureDetector(
          onHorizontalDragUpdate: (details) {
            final fraction = details.localPosition.dx / constraints.maxWidth;
            onSeek(duration * fraction.clamp(0.0, 1.0));
          },
          onTapDown: (details) {
            final fraction = details.localPosition.dx / constraints.maxWidth;
            onSeek(duration * fraction.clamp(0.0, 1.0));
          },
          child: CustomPaint(
            size: Size(constraints.maxWidth, 4),
            painter: _ProgressPainter(
              position: position.inMilliseconds / duration.inMilliseconds,
              buffered: buffered.isNotEmpty
                  ? buffered.last.end.inMilliseconds / duration.inMilliseconds
                  : 0.0,
            ),
          ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Picture-in-Picture (requires platform channel):

// Dart side
static const _channel = MethodChannel('app/pip');
bool _isPipActive = false;

Future<void> _enterPip() async {
  if (Platform.isAndroid) {
    final result = await _channel.invokeMethod('enterPip', {
      'aspectRatioWidth': 16,
      'aspectRatioHeight': 9,
    });
    _isPipActive = result == true;
  }
}

// Android side (Kotlin)
// In MainActivity:
override fun onPictureInPictureModeChanged(isInPip: Boolean, newConfig: Configuration) {
    super.onPictureInPictureModeChanged(isInPip, newConfig)
    // Notify Flutter via MethodChannel
    methodChannel.invokeMethod("pipModeChanged", isInPip)
}

private fun enterPip(aspectW: Int, aspectH: Int): Boolean {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val params = PictureInPictureParams.Builder()
            .setAspectRatio(Rational(aspectW, aspectH))
            .build()
        return enterPictureInPictureMode(params)
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

Key details: PiP is Android 8.0+ only. iOS uses a different API (AVPictureInPictureController) that only works with native AVPlayer, making Flutter PiP on iOS much harder -- you typically need to use a platform view with native video player for iOS PiP.


Q9. How would you implement a map with custom markers, clustering, and real-time location tracking?

What the interviewer is REALLY testing:
Whether you understand the performance implications of rendering hundreds of markers, why marker clustering is necessary, how to handle continuous location streams without rebuilding the entire map, and battery/permission considerations.

Answer:

Package stack: google_maps_flutter + flutter_map_marker_cluster (or custom clustering) + geolocator + permission_handler.

Custom markers from widgets (the non-obvious part):

Google Maps Flutter doesn't accept widgets as markers -- it needs BitmapDescriptor. To use custom designs, you must render a widget to an image:

class MarkerGenerator {
  static Future<BitmapDescriptor> fromWidget(Widget widget, Size size) async {
    // Use RepaintBoundary + RenderRepaintBoundary in an offscreen approach,
    // or the simpler WidgetToImageConverter approach:
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);

    // Build the widget into a render object
    final renderObject = RenderRepaintBoundary();
    final pipelineOwner = PipelineOwner();
    final buildOwner = BuildOwner(focusManager: FocusManager());

    final rootElement = RenderObjectToWidgetAdapter<RenderBox>(
      container: renderObject,
      child: Directionality(
        textDirection: TextDirection.ltr,
        child: MediaQuery(
          data: const MediaQueryData(),
          child: SizedBox(
            width: size.width,
            height: size.height,
            child: widget,
          ),
        ),
      ),
    ).attachToRenderTree(buildOwner);

    buildOwner.buildScope(rootElement);
    pipelineOwner.flushLayout();
    pipelineOwner.flushCompositingBits();
    pipelineOwner.flushPaint();

    final image = await renderObject.toImage(pixelRatio: 2.0);
    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    return BitmapDescriptor.bytes(byteData!.buffer.asUint8List());
  }
}
Enter fullscreen mode Exit fullscreen mode

Marker clustering (simplified algorithm):

class MarkerCluster {
  /// Groups nearby markers based on zoom level
  static List<ClusterItem> cluster({
    required List<MapMarker> markers,
    required double zoomLevel,
    required double clusterRadius,
  }) {
    final clusters = <ClusterItem>[];
    final processed = <int>{};

    for (int i = 0; i < markers.length; i++) {
      if (processed.contains(i)) continue;

      final nearby = <MapMarker>[markers[i]];
      processed.add(i);

      for (int j = i + 1; j < markers.length; j++) {
        if (processed.contains(j)) continue;

        final distance = _pixelDistance(
          markers[i].position, markers[j].position, zoomLevel,
        );

        if (distance < clusterRadius) {
          nearby.add(markers[j]);
          processed.add(j);
        }
      }

      if (nearby.length == 1) {
        clusters.add(ClusterItem.single(nearby.first));
      } else {
        clusters.add(ClusterItem.cluster(
          position: _centroid(nearby),
          count: nearby.length,
          children: nearby,
        ));
      }
    }

    return clusters;
  }
}
Enter fullscreen mode Exit fullscreen mode

Real-time location tracking:

class LocationTracker {
  final Geolocator _geolocator = Geolocator();
  StreamSubscription<Position>? _positionSub;

  Stream<Position> startTracking() {
    final controller = StreamController<Position>();

    _positionSub = Geolocator.getPositionStream(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 10, // only emit when moved 10+ meters
      ),
    ).listen(
      (position) => controller.add(position),
      onError: (e) => controller.addError(e),
    );

    return controller.stream;
  }

  void updateUserMarker(Position position, GoogleMapController mapController) {
    // Animate camera to follow user
    mapController.animateCamera(
      CameraUpdate.newLatLng(
        LatLng(position.latitude, position.longitude),
      ),
    );

    // Update the user's marker position without rebuilding the map
    // Use a ValueNotifier<Set<Marker>> and only replace the user marker
    _markers.value = {
      ..._markers.value.where((m) => m.markerId.value != 'user'),
      Marker(
        markerId: const MarkerId('user'),
        position: LatLng(position.latitude, position.longitude),
        icon: _userMarkerIcon,
        anchor: const Offset(0.5, 0.5),
        rotation: position.heading, // rotate marker to face direction of travel
      ),
    };
  }

  void dispose() {
    _positionSub?.cancel();
  }
}
Enter fullscreen mode Exit fullscreen mode

Battery considerations:

  • Use distanceFilter to avoid processing every GPS tick.
  • Switch to LocationAccuracy.low when the app is in the background.
  • On Android, use a foreground service with notification for background tracking.
  • On iOS, enable "Location updates" in Background Modes.

Q10. How would you implement offline-first CRUD with background sync?

What the interviewer is REALLY testing:
Whether you understand conflict resolution strategies, optimistic vs pessimistic updates, sync queues, idempotency, and how to handle the "last-write-wins" vs "merge" dilemma.

Answer:

Architecture:

┌───────────────────────┐
│      UI Layer          │
│  (reads from local DB) │
├───────────────────────┤
│    Repository Layer    │
│  Write → Local DB      │
│  Write → Sync Queue    │
├──────────┬────────────┤
│ Local DB │ Sync Queue  │
│ (Drift)  │ (Drift tbl) │
├──────────┴────────────┤
│   SyncEngine           │
│   - Watches connectivity│
│   - Processes queue     │
│   - Handles conflicts   │
├───────────────────────┤
│   Remote API           │
└───────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Sync queue table:

class SyncQueue extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get entityType => text()(); // 'task', 'note', etc.
  TextColumn get entityId => text()();
  TextColumn get operation => text()(); // 'create', 'update', 'delete'
  TextColumn get payload => text()(); // JSON of the entity
  DateTimeColumn get createdAt => dateTime()();
  IntColumn get retryCount => integer().withDefault(const Constant(0))();
  TextColumn get idempotencyKey => text()(); // prevent duplicate operations
}
Enter fullscreen mode Exit fullscreen mode

Repository with offline-first write pattern:

class TaskRepository {
  final AppDatabase _db;
  final SyncEngine _syncEngine;

  /// Create a task -- works offline
  Future<Task> createTask(TaskCreate input) async {
    final task = Task(
      id: const Uuid().v4(), // client-generated UUID
      title: input.title,
      description: input.description,
      createdAt: DateTime.now(),
      updatedAt: DateTime.now(),
      syncStatus: SyncStatus.pending,
    );

    // Write to local DB first (instant UI update)
    await _db.tasksDao.insert(task);

    // Enqueue for sync
    await _db.syncQueueDao.enqueue(
      entityType: 'task',
      entityId: task.id,
      operation: 'create',
      payload: jsonEncode(task.toJson()),
      idempotencyKey: 'create_${task.id}', // idempotent: retrying won't duplicate
    );

    // Trigger sync attempt (non-blocking)
    _syncEngine.triggerSync();

    return task;
  }

  /// Read always from local DB -- single source of truth for UI
  Stream<List<Task>> watchAllTasks() => _db.tasksDao.watchAll();

  Future<Task> updateTask(String id, TaskUpdate input) async {
    final existing = await _db.tasksDao.getById(id);

    final updated = existing.copyWith(
      title: input.title ?? existing.title,
      description: input.description ?? existing.description,
      updatedAt: DateTime.now(),
      syncStatus: SyncStatus.pending,
    );

    await _db.tasksDao.updateTask(updated);

    // Collapse: if there's already a pending 'update' for this entity,
    // replace the payload instead of adding another queue entry
    await _db.syncQueueDao.upsertForEntity(
      entityType: 'task',
      entityId: id,
      operation: existing.syncStatus == SyncStatus.pendingCreate ? 'create' : 'update',
      payload: jsonEncode(updated.toJson()),
      idempotencyKey: 'update_${id}_${DateTime.now().millisecondsSinceEpoch}',
    );

    _syncEngine.triggerSync();
    return updated;
  }
}
Enter fullscreen mode Exit fullscreen mode

Sync engine:

class SyncEngine {
  final AppDatabase _db;
  final ApiClient _api;
  final Connectivity _connectivity;
  StreamSubscription? _connectivitySub;
  bool _isSyncing = false;

  void start() {
    _connectivitySub = _connectivity.onConnectivityChanged.listen((status) {
      if (status != ConnectivityResult.none) {
        triggerSync();
      }
    });
  }

  Future<void> triggerSync() async {
    if (_isSyncing) return; // prevent concurrent syncs
    _isSyncing = true;

    try {
      // Process queue in order (FIFO)
      while (true) {
        final entry = await _db.syncQueueDao.getOldest();
        if (entry == null) break; // queue empty

        try {
          await _processEntry(entry);
          await _db.syncQueueDao.delete(entry.id);
        } on ConflictException catch (e) {
          await _resolveConflict(entry, e.serverVersion);
          await _db.syncQueueDao.delete(entry.id);
        } on NetworkException {
          break; // lost connection, stop processing
        } catch (e) {
          // Increment retry count, skip if too many failures
          if (entry.retryCount >= 5) {
            await _db.syncQueueDao.delete(entry.id);
            await _db.syncErrorsDao.log(entry, e.toString());
          } else {
            await _db.syncQueueDao.incrementRetry(entry.id);
          }
        }
      }

      // After pushing local changes, pull remote changes
      await _pullRemoteChanges();
    } finally {
      _isSyncing = false;
    }
  }

  Future<void> _processEntry(SyncQueueEntry entry) async {
    switch (entry.operation) {
      case 'create':
        await _api.createTask(
          jsonDecode(entry.payload),
          idempotencyKey: entry.idempotencyKey,
        );
      case 'update':
        await _api.updateTask(
          entry.entityId,
          jsonDecode(entry.payload),
          idempotencyKey: entry.idempotencyKey,
        );
      case 'delete':
        await _api.deleteTask(
          entry.entityId,
          idempotencyKey: entry.idempotencyKey,
        );
    }

    // Mark entity as synced in local DB
    await _db.tasksDao.updateSyncStatus(entry.entityId, SyncStatus.synced);
  }

  Future<void> _resolveConflict(SyncQueueEntry local, Map<String, dynamic> serverVersion) async {
    // Strategy: Last-write-wins based on updatedAt timestamp
    final localTask = Task.fromJson(jsonDecode(local.payload));
    final serverTask = Task.fromJson(serverVersion);

    if (localTask.updatedAt.isAfter(serverTask.updatedAt)) {
      // Local wins -- force push
      await _api.updateTask(local.entityId, localTask.toJson(), force: true);
    } else {
      // Server wins -- update local DB
      await _db.tasksDao.updateTask(serverTask.copyWith(syncStatus: SyncStatus.synced));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key insight the interviewer wants to hear: "Offline-first means the local database is the single source of truth for the UI. The UI NEVER reads from the network directly. The sync engine is a background process that reconciles local and remote state."


Q11. How would you handle complex form validation with dependent fields?

What the interviewer is REALLY testing:
Whether you understand reactive validation (validate on change vs on submit), cross-field dependencies (e.g., "confirm password" depends on "password"), async validation (e.g., "check if username is available"), and how to avoid rebuilding the entire form on every keystroke.

Answer:

class RegistrationFormModel extends ChangeNotifier {
  // Field values
  String _email = '';
  String _password = '';
  String _confirmPassword = '';
  String _username = '';
  String _dateOfBirth = '';

  // Async validation state
  bool _isCheckingUsername = false;
  bool? _isUsernameAvailable;

  // Touched tracking -- don't show errors on untouched fields
  final Set<String> _touchedFields = {};

  Timer? _usernameDebounce;

  // === Setters that trigger dependent validation ===

  void setEmail(String v) { _email = v; _touch('email'); notifyListeners(); }

  void setPassword(String v) {
    _password = v;
    _touch('password');
    // Password change invalidates confirmPassword validation
    notifyListeners(); // triggers rebuild that shows confirmPassword error if mismatched
  }

  void setConfirmPassword(String v) {
    _confirmPassword = v;
    _touch('confirmPassword');
    notifyListeners();
  }

  void setUsername(String v) {
    _username = v;
    _touch('username');
    _isUsernameAvailable = null;
    notifyListeners();

    // Debounced async check
    _usernameDebounce?.cancel();
    if (usernameLocalError == null && v.isNotEmpty) {
      _usernameDebounce = Timer(const Duration(milliseconds: 600), () async {
        _isCheckingUsername = true;
        notifyListeners();

        _isUsernameAvailable = await _api.checkUsernameAvailable(v);
        _isCheckingUsername = false;
        notifyListeners();
      });
    }
  }

  void setDateOfBirth(String v) { _dateOfBirth = v; _touch('dob'); notifyListeners(); }

  void _touch(String field) => _touchedFields.add(field);

  // === Validators (only return errors for touched fields) ===

  String? get emailError {
    if (!_touchedFields.contains('email')) return null;
    if (_email.isEmpty) return 'Email is required';
    if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_email)) {
      return 'Invalid email format';
    }
    return null;
  }

  String? get passwordError {
    if (!_touchedFields.contains('password')) return null;
    if (_password.isEmpty) return 'Password is required';
    if (_password.length < 8) return 'At least 8 characters';
    if (!_password.contains(RegExp(r'[A-Z]'))) return 'Needs an uppercase letter';
    if (!_password.contains(RegExp(r'[0-9]'))) return 'Needs a digit';
    return null;
  }

  String? get confirmPasswordError {
    if (!_touchedFields.contains('confirmPassword')) return null;
    if (_confirmPassword.isEmpty) return 'Please confirm password';
    if (_confirmPassword != _password) return 'Passwords do not match';
    return null;
  }

  String? get usernameLocalError {
    if (!_touchedFields.contains('username')) return null;
    if (_username.isEmpty) return 'Username is required';
    if (_username.length < 3) return 'At least 3 characters';
    if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(_username)) {
      return 'Only letters, numbers, and underscores';
    }
    return null;
  }

  String? get usernameError {
    final local = usernameLocalError;
    if (local != null) return local;
    if (_isCheckingUsername) return null; // show loading indicator instead
    if (_isUsernameAvailable == false) return 'Username is taken';
    return null;
  }

  bool get isCheckingUsername => _isCheckingUsername;

  String? get dobError {
    if (!_touchedFields.contains('dob')) return null;
    if (_dateOfBirth.isEmpty) return 'Date of birth is required';
    final dob = DateTime.tryParse(_dateOfBirth);
    if (dob == null) return 'Invalid date';
    final age = DateTime.now().difference(dob).inDays ~/ 365;
    if (age < 13) return 'Must be at least 13 years old';
    return null;
  }

  /// Touch all fields and check if form is valid (for submit button)
  bool validateAll() {
    _touchedFields.addAll(['email', 'password', 'confirmPassword', 'username', 'dob']);
    notifyListeners();
    return emailError == null &&
        passwordError == null &&
        confirmPasswordError == null &&
        usernameError == null &&
        dobError == null &&
        _isUsernameAvailable == true;
  }

  /// Can the submit button be enabled?
  bool get canSubmit =>
      _email.isNotEmpty &&
      _password.isNotEmpty &&
      _confirmPassword.isNotEmpty &&
      _username.isNotEmpty &&
      _dateOfBirth.isNotEmpty &&
      !_isCheckingUsername;
}
Enter fullscreen mode Exit fullscreen mode

UI with granular rebuilds:

class RegistrationForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<RegistrationFormModel>(
      builder: (context, form, _) {
        return Column(
          children: [
            TextField(
              onChanged: form.setEmail,
              decoration: InputDecoration(
                labelText: 'Email',
                errorText: form.emailError,
              ),
            ),
            TextField(
              onChanged: form.setUsername,
              decoration: InputDecoration(
                labelText: 'Username',
                errorText: form.usernameError,
                suffixIcon: form.isCheckingUsername
                    ? const SizedBox(
                        width: 20, height: 20,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : form._isUsernameAvailable == true
                        ? const Icon(Icons.check, color: Colors.green)
                        : null,
              ),
            ),
            TextField(
              onChanged: form.setPassword,
              obscureText: true,
              decoration: InputDecoration(
                labelText: 'Password',
                errorText: form.passwordError,
              ),
            ),
            TextField(
              onChanged: form.setConfirmPassword,
              obscureText: true,
              decoration: InputDecoration(
                labelText: 'Confirm Password',
                errorText: form.confirmPasswordError, // reacts to password changes
              ),
            ),
            ElevatedButton(
              onPressed: form.canSubmit ? () => _submit(context, form) : null,
              child: const Text('Register'),
            ),
          ],
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The subtle trap: If you use Flutter's built-in Form + TextFormField with validator, those validators only run on form.validate() (on submit). They are NOT reactive. For real-time validation with dependent fields, you need a custom approach like the one above. The Form widget's autovalidateMode: AutovalidateMode.onUserInteraction partially solves this, but it doesn't handle cross-field dependencies or async validation.


Q12. How would you implement a multi-step onboarding flow with skip/back support?

What the interviewer is REALLY testing:
Whether you understand PageView with controlled navigation, preserving state across steps, conditional step visibility, analytics tracking at each step, and persisting progress in case the user kills the app mid-onboarding.

Answer:

class OnboardingController extends ChangeNotifier {
  final PageController pageController = PageController();
  final SharedPreferences _prefs;

  int _currentStep = 0;
  late List<OnboardingStep> _steps;
  final Map<String, dynamic> _collectedData = {};

  OnboardingController(this._prefs) {
    _currentStep = _prefs.getInt('onboarding_step') ?? 0;
    final savedData = _prefs.getString('onboarding_data');
    if (savedData != null) {
      _collectedData.addAll(jsonDecode(savedData));
    }

    // Steps can be conditional based on previously collected data
    _steps = _buildSteps();
  }

  List<OnboardingStep> _buildSteps() {
    return [
      const OnboardingStep(
        id: 'welcome',
        title: 'Welcome',
        isSkippable: false,
      ),
      const OnboardingStep(
        id: 'profile',
        title: 'Set Up Profile',
        isSkippable: true,
      ),
      const OnboardingStep(
        id: 'interests',
        title: 'Your Interests',
        isSkippable: true,
      ),
      // Conditionally show notification step
      if (!(_collectedData['notifications_already_granted'] == true))
        const OnboardingStep(
          id: 'notifications',
          title: 'Enable Notifications',
          isSkippable: true,
        ),
      const OnboardingStep(
        id: 'ready',
        title: 'All Set!',
        isSkippable: false,
      ),
    ];
  }

  int get currentStep => _currentStep;
  int get totalSteps => _steps.length;
  OnboardingStep get current => _steps[_currentStep];
  bool get canGoBack => _currentStep > 0;
  bool get isLastStep => _currentStep == _steps.length - 1;
  double get progress => (_currentStep + 1) / _steps.length;

  void saveStepData(String key, dynamic value) {
    _collectedData[key] = value;
    _persistProgress();
  }

  Future<void> next() async {
    if (isLastStep) {
      await _completeOnboarding();
      return;
    }

    _currentStep++;
    _steps = _buildSteps(); // recalculate conditional steps
    _persistProgress();
    notifyListeners();
    pageController.animateToPage(
      _currentStep,
      duration: const Duration(milliseconds: 400),
      curve: Curves.easeInOut,
    );

    _trackStep('next');
  }

  void back() {
    if (!canGoBack) return;
    _currentStep--;
    _persistProgress();
    notifyListeners();
    pageController.animateToPage(
      _currentStep,
      duration: const Duration(milliseconds: 400),
      curve: Curves.easeInOut,
    );

    _trackStep('back');
  }

  void skip() {
    _trackStep('skip');
    next();
  }

  void _persistProgress() {
    _prefs.setInt('onboarding_step', _currentStep);
    _prefs.setString('onboarding_data', jsonEncode(_collectedData));
  }

  Future<void> _completeOnboarding() async {
    await _prefs.setBool('onboarding_complete', true);
    await _prefs.remove('onboarding_step');
    await _prefs.remove('onboarding_data');
    // Send collected data to server
    await _api.submitOnboardingData(_collectedData);
    notifyListeners(); // triggers navigation to home
  }

  void _trackStep(String action) {
    _analytics.logEvent('onboarding_$action', {
      'step_id': current.id,
      'step_index': _currentStep,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

UI:

class OnboardingScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<OnboardingController>(
      builder: (context, controller, _) {
        return Scaffold(
          body: SafeArea(
            child: Column(
              children: [
                // Progress indicator
                LinearProgressIndicator(value: controller.progress),
                const SizedBox(height: 8),
                Text('Step ${controller.currentStep + 1} of ${controller.totalSteps}'),

                // Pages
                Expanded(
                  child: PageView.builder(
                    controller: controller.pageController,
                    physics: const NeverScrollableScrollPhysics(), // disable swipe
                    itemCount: controller.totalSteps,
                    itemBuilder: (context, index) {
                      return _buildStepContent(context, controller, index);
                    },
                  ),
                ),

                // Navigation buttons
                Padding(
                  padding: const EdgeInsets.all(16),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      if (controller.canGoBack)
                        TextButton(
                          onPressed: controller.back,
                          child: const Text('Back'),
                        )
                      else
                        const SizedBox.shrink(),

                      Row(
                        children: [
                          if (controller.current.isSkippable)
                            TextButton(
                              onPressed: controller.skip,
                              child: const Text('Skip'),
                            ),
                          const SizedBox(width: 8),
                          ElevatedButton(
                            onPressed: controller.next,
                            child: Text(controller.isLastStep ? 'Get Started' : 'Next'),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points: Disabling PageView swipe with NeverScrollableScrollPhysics ensures navigation only happens through the controller (so you can validate before advancing). Persisting step progress means the user can kill the app and resume where they left off.


Q13. How would you implement local push notifications with scheduling?

What the interviewer is REALLY testing:
Whether you understand platform-specific notification channels (Android 8+), the difference between immediate and scheduled notifications, how to handle notification taps (deep linking), and timezone-aware scheduling.

Answer:

class NotificationService {
  final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();

  Future<void> initialize() async {
    const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: false, // request separately for better UX
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    await _plugin.initialize(
      const InitializationSettings(android: androidSettings, iOS: iosSettings),
      onDidReceiveNotificationResponse: _onNotificationTap,
    );

    // Create Android notification channels
    await _createChannels();
  }

  Future<void> _createChannels() async {
    final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>();

    await androidPlugin?.createNotificationChannel(const AndroidNotificationChannel(
      'reminders',
      'Reminders',
      description: 'Task reminders and deadlines',
      importance: Importance.high,
    ));

    await androidPlugin?.createNotificationChannel(const AndroidNotificationChannel(
      'updates',
      'Updates',
      description: 'App updates and news',
      importance: Importance.defaultImportance,
    ));
  }

  Future<bool> requestPermission() async {
    if (Platform.isIOS) {
      final iosPlugin = _plugin.resolvePlatformSpecificImplementation<
          IOSFlutterLocalNotificationsPlugin>();
      final granted = await iosPlugin?.requestPermissions(
        alert: true,
        badge: true,
        sound: true,
      );
      return granted ?? false;
    }

    if (Platform.isAndroid) {
      final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>();
      // Android 13+ requires explicit notification permission
      final granted = await androidPlugin?.requestNotificationsPermission();
      return granted ?? true; // pre-Android 13 always has permission
    }

    return false;
  }

  /// Show an immediate notification
  Future<void> showNow({
    required int id,
    required String title,
    required String body,
    String? payload,
    String channelId = 'reminders',
  }) async {
    await _plugin.show(
      id,
      title,
      body,
      NotificationDetails(
        android: AndroidNotificationDetails(
          channelId,
          channelId == 'reminders' ? 'Reminders' : 'Updates',
          priority: Priority.high,
          importance: Importance.high,
        ),
        iOS: const DarwinNotificationDetails(
          presentAlert: true,
          presentBadge: true,
          presentSound: true,
        ),
      ),
      payload: payload,
    );
  }

  /// Schedule a notification at a specific time (timezone-aware)
  Future<void> scheduleAt({
    required int id,
    required String title,
    required String body,
    required DateTime scheduledTime,
    String? payload,
  }) async {
    final tzTime = tz.TZDateTime.from(scheduledTime, tz.local);

    await _plugin.zonedSchedule(
      id,
      title,
      body,
      tzTime,
      const NotificationDetails(
        android: AndroidNotificationDetails(
          'reminders',
          'Reminders',
          priority: Priority.high,
          importance: Importance.high,
        ),
        iOS: DarwinNotificationDetails(),
      ),
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
      matchDateTimeComponents: null, // one-time; use DateTimeComponents.time for daily repeat
      payload: payload,
    );
  }

  /// Schedule a daily repeating notification
  Future<void> scheduleDailyAt({
    required int id,
    required String title,
    required String body,
    required int hour,
    required int minute,
  }) async {
    await _plugin.zonedSchedule(
      id,
      title,
      body,
      _nextInstanceOfTime(hour, minute),
      const NotificationDetails(
        android: AndroidNotificationDetails('reminders', 'Reminders'),
        iOS: DarwinNotificationDetails(),
      ),
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time, // repeats daily
    );
  }

  tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
    final now = tz.TZDateTime.now(tz.local);
    var scheduled = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
    if (scheduled.isBefore(now)) {
      scheduled = scheduled.add(const Duration(days: 1));
    }
    return scheduled;
  }

  /// Handle notification tap -- deep link to correct screen
  void _onNotificationTap(NotificationResponse response) {
    final payload = response.payload;
    if (payload == null) return;

    final data = jsonDecode(payload) as Map<String, dynamic>;
    final route = data['route'] as String?;
    final id = data['id'] as String?;

    // Use your app's navigation to go to the right screen
    _navigator.pushNamed(route ?? '/', arguments: {'id': id});
  }

  Future<void> cancelNotification(int id) => _plugin.cancel(id);
  Future<void> cancelAll() => _plugin.cancelAll();

  /// Get list of pending notifications (useful for debugging/settings UI)
  Future<List<PendingNotificationRequest>> getPending() =>
      _plugin.pendingNotificationRequests();
}
Enter fullscreen mode Exit fullscreen mode

Initialization in main.dart:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  tz.initializeTimeZones();
  tz.setLocalLocation(tz.getLocation('Asia/Kolkata')); // or detect dynamically

  final notificationService = NotificationService();
  await notificationService.initialize();

  // Handle notification that launched the app (app was terminated)
  final launchDetails = await notificationService._plugin
      .getNotificationAppLaunchDetails();
  final initialRoute = _routeFromLaunchDetails(launchDetails);

  runApp(MyApp(initialRoute: initialRoute));
}
Enter fullscreen mode Exit fullscreen mode

Trap: On Android 12+, exact alarms require SCHEDULE_EXACT_ALARM permission and the user can revoke it in system settings. You need AndroidScheduleMode.exactAllowWhileIdle and should gracefully handle the case where exact scheduling is denied.


Q14. How would you implement a dynamic feature module / lazy loading of app features?

What the interviewer is REALLY testing:
Whether you understand deferred imports in Dart, code splitting boundaries, how to load features on demand to reduce initial app size, and the difference between Dart's deferred loading and Android's dynamic feature modules.

Answer:

Dart natively supports deferred loading via deferred as imports. On Flutter Web, this translates to actual code splitting (separate JS chunks). On mobile, all code is compiled into the binary, but you can still defer initialization and UI loading.

Approach 1: Dart deferred imports (works on Web, partial benefit on mobile):

// feature_registry.dart
import 'features/analytics_dashboard.dart' deferred as analytics;
import 'features/admin_panel.dart' deferred as admin;
import 'features/video_editor.dart' deferred as videoEditor;

class FeatureRegistry {
  static final Map<String, DeferredFeature> _features = {
    'analytics': DeferredFeature(
      loader: () => analytics.loadLibrary(),
      builder: () => analytics.AnalyticsDashboard(),
    ),
    'admin': DeferredFeature(
      loader: () => admin.loadLibrary(),
      builder: () => admin.AdminPanel(),
    ),
    'video_editor': DeferredFeature(
      loader: () => videoEditor.loadLibrary(),
      builder: () => videoEditor.VideoEditorScreen(),
    ),
  };

  static Future<Widget> load(String featureId) async {
    final feature = _features[featureId];
    if (feature == null) throw FeatureNotFound(featureId);
    await feature.loader();
    return feature.builder();
  }
}

class DeferredFeature {
  final Future<void> Function() loader;
  final Widget Function() builder;
  const DeferredFeature({required this.loader, required this.builder});
}
Enter fullscreen mode Exit fullscreen mode

Approach 2: Feature module architecture with runtime loading:

/// Abstract contract for a feature module
abstract class FeatureModule {
  String get id;
  String get name;
  String get description;
  List<String> get requiredPermissions;

  /// Whether this feature is available for the current user
  Future<bool> isAvailable(UserProfile user);

  /// Initialize the feature (load resources, register routes)
  Future<void> initialize();

  /// The entry point widget
  Widget buildEntry(BuildContext context);

  /// Cleanup when feature is unloaded
  Future<void> dispose();
}

/// Feature loader that handles lazy initialization
class FeatureLoader extends StatefulWidget {
  final String featureId;
  const FeatureLoader({required this.featureId});

  @override
  State<FeatureLoader> createState() => _FeatureLoaderState();
}

class _FeatureLoaderState extends State<FeatureLoader> {
  late Future<Widget> _featureFuture;

  @override
  void initState() {
    super.initState();
    _featureFuture = _loadFeature();
  }

  Future<Widget> _loadFeature() async {
    final module = FeatureRegistry.getModule(widget.featureId);

    final user = context.read<UserProfile>();
    if (!await module.isAvailable(user)) {
      return const FeatureUnavailableScreen();
    }

    await module.initialize();
    return module.buildEntry(context);
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<Widget>(
      future: _featureFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(
            body: Center(child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                CircularProgressIndicator(),
                SizedBox(height: 16),
                Text('Loading feature...'),
              ],
            )),
          );
        }

        if (snapshot.hasError) {
          return Scaffold(
            body: Center(child: Text('Failed to load: ${snapshot.error}')),
          );
        }

        return snapshot.data!;
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Approach 3: Server-driven feature flags controlling which modules load:

class FeatureGateService {
  final ApiClient _api;
  final Map<String, bool> _gates = {};

  Future<void> fetchGates() async {
    final response = await _api.get('/api/feature-gates');
    _gates.addAll(Map<String, bool>.from(response.data));
  }

  bool isEnabled(String featureId) => _gates[featureId] ?? false;

  Widget gated({
    required String featureId,
    required Widget child,
    Widget? fallback,
  }) {
    if (isEnabled(featureId)) return child;
    return fallback ?? const SizedBox.shrink();
  }
}

// Usage in navigation
GoRoute(
  path: '/analytics',
  builder: (context, state) {
    final gates = context.read<FeatureGateService>();
    if (!gates.isEnabled('analytics_v2')) {
      return const UpgradePromptScreen();
    }
    return const FeatureLoader(featureId: 'analytics');
  },
),
Enter fullscreen mode Exit fullscreen mode

Key insight: On Flutter Web, deferred as creates actual separate JavaScript bundles that are fetched over the network on demand. On mobile (AOT-compiled), the code is already in the binary, so loadLibrary() completes instantly. The real value on mobile comes from deferring initialization work (database setup, service registration) rather than code loading.


Q15. How would you implement a settings screen with persistent preferences?

What the interviewer is REALLY testing:
Whether you understand the layered approach (UI, state, persistence), how to make settings reactive throughout the app (not just on the settings screen), type safety for preferences, and migration when settings schema changes across app versions.

Answer:

/// Type-safe settings keys with defaults and migration support
class AppSettings {
  final SharedPreferences _prefs;

  AppSettings(this._prefs);

  // === Setting definitions with type safety ===

  static const _themeMode = _SettingKey<String>('theme_mode', 'system');
  static const _language = _SettingKey<String>('language', 'en');
  static const _notificationsEnabled = _SettingKey<bool>('notifications_enabled', true);
  static const _dailyReminderHour = _SettingKey<int>('daily_reminder_hour', 9);
  static const _fontSize = _SettingKey<double>('font_size_scale', 1.0);
  static const _dataUsage = _SettingKey<String>('data_usage', 'wifi_only');
  static const _biometricEnabled = _SettingKey<bool>('biometric_enabled', false);
  static const _settingsVersion = _SettingKey<int>('settings_version', 0);

  // === Getters ===

  ThemeMode get themeMode => switch (_getString(_themeMode)) {
    'light' => ThemeMode.light,
    'dark' => ThemeMode.dark,
    _ => ThemeMode.system,
  };

  String get language => _getString(_language);
  bool get notificationsEnabled => _getBool(_notificationsEnabled);
  int get dailyReminderHour => _getInt(_dailyReminderHour);
  double get fontSizeScale => _getDouble(_fontSize);
  DataUsagePolicy get dataUsage => DataUsagePolicy.fromString(_getString(_dataUsage));
  bool get biometricEnabled => _getBool(_biometricEnabled);

  // === Type-safe internal getters ===

  String _getString(_SettingKey<String> key) =>
      _prefs.getString(key.name) ?? key.defaultValue;
  bool _getBool(_SettingKey<bool> key) =>
      _prefs.getBool(key.name) ?? key.defaultValue;
  int _getInt(_SettingKey<int> key) =>
      _prefs.getInt(key.name) ?? key.defaultValue;
  double _getDouble(_SettingKey<double> key) =>
      _prefs.getDouble(key.name) ?? key.defaultValue;

  // === Migration ===

  Future<void> migrateIfNeeded() async {
    final currentVersion = _getInt(_settingsVersion);

    if (currentVersion < 1) {
      // v0 -> v1: "dark_mode" bool was replaced with "theme_mode" string
      final oldDarkMode = _prefs.getBool('dark_mode');
      if (oldDarkMode != null) {
        await _prefs.setString('theme_mode', oldDarkMode ? 'dark' : 'light');
        await _prefs.remove('dark_mode');
      }
    }

    if (currentVersion < 2) {
      // v1 -> v2: renamed "push_enabled" to "notifications_enabled"
      final old = _prefs.getBool('push_enabled');
      if (old != null) {
        await _prefs.setBool('notifications_enabled', old);
        await _prefs.remove('push_enabled');
      }
    }

    await _prefs.setInt(_settingsVersion.name, 2); // current version
  }
}

class _SettingKey<T> {
  final String name;
  final T defaultValue;
  const _SettingKey(this.name, this.defaultValue);
}
Enter fullscreen mode Exit fullscreen mode

Reactive settings notifier:

class SettingsNotifier extends ChangeNotifier {
  final AppSettings _settings;
  final SharedPreferences _prefs;
  final NotificationService _notificationService;

  SettingsNotifier(this._settings, this._prefs, this._notificationService);

  AppSettings get settings => _settings;

  Future<void> setThemeMode(ThemeMode mode) async {
    final value = switch (mode) {
      ThemeMode.light => 'light',
      ThemeMode.dark => 'dark',
      ThemeMode.system => 'system',
    };
    await _prefs.setString('theme_mode', value);
    notifyListeners(); // rebuilds MaterialApp, which applies new theme
  }

  Future<void> setLanguage(String langCode) async {
    await _prefs.setString('language', langCode);
    notifyListeners(); // rebuilds MaterialApp, which applies new locale
  }

  Future<void> setNotificationsEnabled(bool enabled) async {
    if (enabled) {
      final granted = await _notificationService.requestPermission();
      if (!granted) return; // can't enable without permission
    }
    await _prefs.setBool('notifications_enabled', enabled);
    if (!enabled) {
      await _notificationService.cancelAll();
    }
    notifyListeners();
  }

  Future<void> setDailyReminderHour(int hour) async {
    await _prefs.setInt('daily_reminder_hour', hour);
    // Reschedule the daily notification
    await _notificationService.scheduleDailyAt(
      id: 0,
      title: 'Daily Reminder',
      body: 'Check your tasks for today',
      hour: hour,
      minute: 0,
    );
    notifyListeners();
  }

  Future<void> setFontSizeScale(double scale) async {
    await _prefs.setDouble('font_size_scale', scale.clamp(0.8, 1.5));
    notifyListeners();
  }

  Future<void> resetToDefaults() async {
    await _prefs.clear();
    notifyListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

Wiring into MaterialApp for app-wide reactivity:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<SettingsNotifier>(
      builder: (context, settingsNotifier, _) {
        final settings = settingsNotifier.settings;
        return MaterialApp(
          themeMode: settings.themeMode,
          theme: AppThemeFactory.build(AppThemeConfig.defaultLight),
          darkTheme: AppThemeFactory.build(AppThemeConfig.defaultDark),
          locale: Locale(settings.language),
          builder: (context, child) {
            // Apply font size scaling
            final mediaQuery = MediaQuery.of(context);
            return MediaQuery(
              data: mediaQuery.copyWith(
                textScaler: TextScaler.linear(settings.fontSizeScale),
              ),
              child: child!,
            );
          },
          home: const HomeScreen(),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Why migration matters: Every settings refactor risks losing user preferences. The version-based migration pattern ensures that when a key is renamed or a type changes, existing users' preferences carry forward seamlessly. Without this, a user who upgraded the app would find all their settings reset to defaults.


SECTION 1: SYSTEM DESIGN FOR MOBILE (Flutter)

These questions test whether a candidate can think beyond code into architecture, scalability, and production-grade concerns. The interviewer wants to see structured thinking, trade-off analysis, and awareness of mobile-specific constraints.


Q1. Design the architecture for a food delivery app like Swiggy/Zomato

What the interviewer is REALLY testing:
Whether you can decompose a large app into bounded contexts, choose appropriate state management per feature, design for real-time updates (order tracking), and handle the complexity of multiple user roles (customer, delivery partner, restaurant).

Answer:

High-level module decomposition:

┌────────────────────────────────────────────────────────┐
│                    App Shell                            │
│  (Authentication, Navigation, Theme, DI Container)      │
├──────────┬──────────┬───────────┬──────────┬───────────┤
│  Home    │  Search  │  Cart &   │  Order   │  Profile  │
│  & Feed  │  & Filter│  Checkout │  Tracking│  & Settings│
├──────────┴──────────┴───────────┴──────────┴───────────┤
│                  Shared Services                        │
│  Location │ Auth │ Payments │ Notifications │ Analytics │
├─────────────────────────────────────────────────────────┤
│                  Data Layer                              │
│  API Client │ Local Cache │ WebSocket │ Image Cache     │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key architectural decisions:

1. State management -- different tools for different jobs:

  • Cart: Global singleton (Riverpod/Provider) -- must persist across screens, survive navigation, and be accessible from anywhere (app bar badge, checkout, restaurant page).
  • Restaurant list/search: Per-screen state with pagination. No need for global state.
  • Order tracking: Dedicated stream-based state connected to WebSocket. Isolated from other state to prevent unnecessary rebuilds.
  • User profile/auth: Global, reactive. Changes here affect the entire app.

2. Real-time order tracking architecture:

class OrderTrackingService {
  final WebSocketChannel _channel;
  final _orderUpdates = StreamController<OrderUpdate>.broadcast();

  Stream<OrderUpdate> trackOrder(String orderId) {
    _channel.sink.add(jsonEncode({
      'action': 'subscribe',
      'order_id': orderId,
    }));

    return _orderUpdates.stream
        .where((update) => update.orderId == orderId);
  }

  // Order states: placed -> confirmed -> preparing ->
  //               picked_up -> on_the_way -> delivered
  // Each state change arrives via WebSocket with:
  // - New status
  // - Delivery partner location (lat/lng) during on_the_way
  // - ETA
  // - Status message
}
Enter fullscreen mode Exit fullscreen mode

3. Restaurant feed with mixed content types:

sealed class FeedItem {
  factory FeedItem.fromJson(Map<String, dynamic> json) => switch (json['type']) {
    'restaurant' => RestaurantCard.fromJson(json),
    'banner' => PromoBanner.fromJson(json),
    'category_strip' => CategoryStrip.fromJson(json),
    'reorder_suggestion' => ReorderSuggestion.fromJson(json),
    _ => UnknownFeedItem(),
  };
}
Enter fullscreen mode Exit fullscreen mode

This allows the server to control the feed layout without app updates.

4. Cart logic -- the hard part:

class CartNotifier extends ChangeNotifier {
  String? _restaurantId; // cart is tied to one restaurant
  final List<CartItem> _items = [];
  DeliveryAddress? _address;

  void addItem(MenuItem item, String restaurantId, List<Customization> customizations) {
    // CRITICAL: If cart has items from a different restaurant, prompt user
    if (_restaurantId != null && _restaurantId != restaurantId) {
      throw CartConflictException(
        currentRestaurant: _restaurantId!,
        newRestaurant: restaurantId,
      );
    }
    _restaurantId = restaurantId;

    // Check if same item with same customizations exists -- increment quantity
    final existing = _items.indexWhere((i) =>
        i.menuItemId == item.id &&
        const DeepCollectionEquality().equals(i.customizations, customizations));

    if (existing != -1) {
      _items[existing] = _items[existing].copyWith(
        quantity: _items[existing].quantity + 1,
      );
    } else {
      _items.add(CartItem(
        menuItemId: item.id,
        name: item.name,
        price: item.price,
        quantity: 1,
        customizations: customizations,
      ));
    }
    notifyListeners();
  }

  // Price calculation
  double get subtotal => _items.fold(0, (sum, item) => sum + item.totalPrice);
  double get deliveryFee => _calculateDeliveryFee();
  double get taxes => subtotal * 0.05; // GST
  double get total => subtotal + deliveryFee + taxes;
}
Enter fullscreen mode Exit fullscreen mode

5. Caching strategy:

  • Restaurant list: Cache with 5-minute TTL. Show cached immediately, refresh in background (stale-while-revalidate).
  • Menu: Cache per restaurant with 15-minute TTL. Menus change less frequently.
  • Images: Aggressive caching with cached_network_image. Restaurant logos almost never change.
  • User profile: Cache indefinitely, invalidate on logout or profile update.

6. Location handling:

  • Request location permission at first use, not at app launch.
  • Use geocoding to convert coordinates to address for display.
  • Allow manual address entry as fallback.
  • Cache recent addresses.

Trade-offs to discuss:

  • Monorepo with feature packages vs single project: Feature packages provide build-time isolation but add complexity.
  • REST vs GraphQL: GraphQL is better for the restaurant page (one query gets restaurant details, menu, reviews, ratings) but REST is simpler for CRUD operations like placing orders.

Q2. Design the architecture for a social media app with feed, stories, and messaging

What the interviewer is REALLY testing:
Whether you understand feed rendering performance (lazy loading, viewport-based media loading), stories as an ephemeral content type, real-time messaging architecture, and the challenges of displaying rich mixed-media content.

Answer:

Architecture layers:

┌──────────────────────────────────────────────────┐
│  Presentation Layer                               │
│  ┌─────────┬──────────┬───────────┬────────────┐ │
│  │  Feed   │  Stories │  Messaging │  Profile   │ │
│  │  Screen │  Viewer  │  Screen    │  Screen    │ │
│  └─────────┴──────────┴───────────┴────────────┘ │
├──────────────────────────────────────────────────┤
│  Domain Layer (Blocs / Notifiers)                │
│  FeedBloc │ StoriesBloc │ ChatBloc │ ProfileBloc │
├──────────────────────────────────────────────────┤
│  Repository Layer                                 │
│  FeedRepo │ StoriesRepo │ ChatRepo │ UserRepo    │
├──────────────────────────────────────────────────┤
│  Data Sources                                     │
│  ┌──────────┬────────────┬───────────┐           │
│  │ REST API │ WebSocket  │ Local DB  │           │
│  │          │ (chat +    │ (Drift)   │           │
│  │          │  presence) │           │           │
│  └──────────┴────────────┴───────────┘           │
├──────────────────────────────────────────────────┤
│  Infrastructure                                   │
│  Image Cache │ Video Cache │ Push │ Analytics    │
└──────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Feed design -- the performance-critical part:

class FeedListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        // Stories bar at the top
        SliverToBoxAdapter(child: StoriesBar()),

        // Feed items
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (context, index) {
              final item = feedItems[index];
              return _FeedItemWidget(
                item: item,
                // Key optimization: only load video when in viewport
                child: VisibilityDetector(
                  key: Key('feed_${item.id}'),
                  onVisibilityChanged: (info) {
                    if (info.visibleFraction > 0.5) {
                      _preloadMedia(item);
                      _trackImpression(item);
                    }
                  },
                  child: FeedCard(item: item),
                ),
              );
            },
          ),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Feed item as a sealed class for type-safe rendering:

sealed class FeedItem {
  String get id;
  UserSummary get author;
  DateTime get createdAt;
}

class PhotoPost extends FeedItem { /* single image */ }
class CarouselPost extends FeedItem { /* multiple images */ }
class VideoPost extends FeedItem { /* video with thumbnail */ }
class TextPost extends FeedItem { /* text only, maybe with link preview */ }
class SharedPost extends FeedItem { /* repost of another FeedItem */ }
class AdPost extends FeedItem { /* sponsored content */ }

// Rendering
Widget buildFeedCard(FeedItem item) => switch (item) {
  PhotoPost p => PhotoFeedCard(post: p),
  CarouselPost p => CarouselFeedCard(post: p),
  VideoPost p => VideoFeedCard(post: p),
  TextPost p => TextFeedCard(post: p),
  SharedPost p => SharedFeedCard(post: p),
  AdPost p => AdFeedCard(post: p),
};
Enter fullscreen mode Exit fullscreen mode

Stories architecture:

class StoriesService {
  // Stories are ephemeral -- 24-hour TTL
  // Pre-fetch next user's stories while current user's stories are playing

  final _storyCache = <String, List<Story>>{}; // userId -> stories

  Future<void> prefetchAdjacentStories(int currentUserIndex, List<String> userIds) async {
    // Prefetch +1 and -1 users' stories
    for (final offset in [-1, 1]) {
      final idx = currentUserIndex + offset;
      if (idx >= 0 && idx < userIds.length) {
        final userId = userIds[idx];
        if (!_storyCache.containsKey(userId)) {
          _storyCache[userId] = await _api.getStories(userId);
          // Also precache images
          for (final story in _storyCache[userId]!) {
            if (story.type == StoryType.image) {
              precacheImage(NetworkImage(story.mediaUrl), _context);
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Story viewer with auto-advance and pause-on-hold:

class StoryViewer extends StatefulWidget {
  @override
  State<StoryViewer> createState() => _StoryViewerState();
}

class _StoryViewerState extends State<StoryViewer>
    with SingleTickerProviderStateMixin {
  late AnimationController _progressController;

  @override
  void initState() {
    super.initState();
    _progressController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 5), // per story
    )..addStatusListener((status) {
      if (status == AnimationStatus.completed) _nextStory();
    });
    _progressController.forward();
  }

  // Pause on long press, resume on release
  void _onLongPressStart(_) => _progressController.stop();
  void _onLongPressEnd(_) => _progressController.forward();

  // Tap left half = previous, right half = next
  void _onTapUp(TapUpDetails details) {
    final screenWidth = MediaQuery.of(context).size.width;
    if (details.globalPosition.dx < screenWidth / 2) {
      _previousStory();
    } else {
      _nextStory();
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onLongPressStart: _onLongPressStart,
      onLongPressEnd: _onLongPressEnd,
      onTapUp: _onTapUp,
      child: Stack(
        children: [
          // Story content (image/video)
          StoryContent(story: _currentStory),
          // Progress bars at top
          Row(
            children: List.generate(
              _stories.length,
              (i) => Expanded(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 2),
                  child: _StoryProgressBar(
                    animation: i == _currentIndex
                        ? _progressController
                        : AlwaysStoppedAnimation(
                            i < _currentIndex ? 1.0 : 0.0,
                          ),
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Messaging architecture -- key considerations:

  • Messages must be stored locally (offline access).
  • Use WebSocket for real-time delivery, with HTTP fallback for reliability.
  • Implement message queue for offline sends (same pattern as Q10 sync queue).
  • Encrypt messages end-to-end if privacy is a requirement (use libsignal protocol).
  • Typing indicators: throttle to max 1 event per 3 seconds per chat.
  • Read receipts: batch them -- don't send a receipt for every single message, send one for the latest read message ID.

Q3. Design a scalable notification system for a Flutter app

What the interviewer is REALLY testing:
Whether you understand the full notification pipeline (server-side triggering, FCM/APNs delivery, client-side handling), notification channels, in-app vs push notifications, and how to handle notifications when the app is in foreground, background, or terminated.

Answer:

Full notification pipeline:

┌─────────────┐     ┌──────────────┐     ┌─────────────┐     ┌──────────┐
│  Trigger     │────>│  Notification │────>│  FCM / APNs │────>│  Device  │
│  (Backend    │     │  Service      │     │  Gateway    │     │          │
│   event,     │     │  (templating, │     │             │     │  Flutter │
│   cron job,  │     │   routing,    │     │             │     │  App     │
│   user action│     │   dedup)     │     │             │     │          │
└─────────────┘     └──────────────┘     └─────────────┘     └──────────┘
Enter fullscreen mode Exit fullscreen mode

Client-side architecture:

class NotificationManager {
  final FirebaseMessaging _fcm = FirebaseMessaging.instance;
  final FlutterLocalNotificationsPlugin _localNotifications;
  final NotificationPreferences _preferences;
  final DeepLinkHandler _deepLinkHandler;

  Future<void> initialize() async {
    // 1. Request permission
    final settings = await _fcm.requestPermission(
      alert: true,
      badge: true,
      sound: true,
      provisional: false, // iOS: set true for quiet notifications first
    );

    if (settings.authorizationStatus == AuthorizationStatus.denied) {
      return; // respect user's choice
    }

    // 2. Get and register token
    final token = await _fcm.getToken();
    await _registerToken(token);

    // 3. Listen for token refresh
    _fcm.onTokenRefresh.listen(_registerToken);

    // 4. Handle foreground messages
    FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

    // 5. Handle background message taps
    FirebaseMessaging.onMessageOpenedApp.listen(_handleMessageTap);

    // 6. Handle terminated state launch
    final initialMessage = await _fcm.getInitialMessage();
    if (initialMessage != null) _handleMessageTap(initialMessage);
  }

  Future<void> _registerToken(String? token) async {
    if (token == null) return;
    await _api.post('/api/devices/register', data: {
      'token': token,
      'platform': Platform.isIOS ? 'ios' : 'android',
      'app_version': _appVersion,
    });
  }

  void _handleForegroundMessage(RemoteMessage message) {
    // App is in foreground -- show in-app notification (not system tray)
    // This is a UX decision: system notifications while using the app are annoying

    final category = message.data['category'];

    // Respect user preferences
    if (!_preferences.isCategoryEnabled(category)) return;

    // Show in-app overlay
    _showInAppNotification(
      title: message.notification?.title ?? '',
      body: message.notification?.body ?? '',
      onTap: () => _deepLinkHandler.handle(message.data['deep_link']),
    );
  }

  void _handleMessageTap(RemoteMessage message) {
    // User tapped the notification -- navigate to the relevant screen
    final deepLink = message.data['deep_link'];
    if (deepLink != null) {
      _deepLinkHandler.handle(deepLink);
    }

    // Track engagement
    _analytics.logEvent('notification_opened', {
      'category': message.data['category'],
      'notification_id': message.data['id'],
    });
  }

  void _showInAppNotification({
    required String title,
    required String body,
    required VoidCallback onTap,
  }) {
    // Use an overlay entry or a package like `another_flushbar`
    // Appears at top of screen, dismissible, tappable
  }
}
Enter fullscreen mode Exit fullscreen mode

Notification preferences (per-category control):

class NotificationPreferences {
  final SharedPreferences _prefs;

  // Category keys map to server-side notification categories
  static const categories = {
    'orders': 'Order updates',
    'promotions': 'Deals and promotions',
    'social': 'Likes, comments, follows',
    'chat': 'New messages',
    'system': 'App updates and security',
  };

  bool isCategoryEnabled(String category) {
    if (category == 'system') return true; // always show security/system
    return _prefs.getBool('notif_$category') ?? true;
  }

  Future<void> setCategoryEnabled(String category, bool enabled) async {
    await _prefs.setBool('notif_$category', enabled);
    // Inform server so it can skip sending notifications for disabled categories
    await _api.post('/api/notifications/preferences', data: {
      'category': category,
      'enabled': enabled,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Topic-based subscription for scalable targeting:

// Instead of sending to individual tokens (doesn't scale),
// use FCM topics for broadcast notifications
Future<void> subscribeToTopics(UserProfile user) async {
  await _fcm.subscribeToTopic('all_users');
  await _fcm.subscribeToTopic('platform_${Platform.isIOS ? 'ios' : 'android'}');
  await _fcm.subscribeToTopic('city_${user.cityId}');

  if (user.isPremium) {
    await _fcm.subscribeToTopic('premium_users');
  }
}
Enter fullscreen mode Exit fullscreen mode

Design considerations:

  • Deduplication: Server should track sent notification IDs. If a retry happens, don't send the same notification twice.
  • Rate limiting: Don't bombard users. Batch non-urgent notifications (e.g., "5 people liked your post" instead of 5 separate notifications).
  • Silent notifications: Use data-only messages for background data sync (e.g., invalidating cache when content changes).
  • Notification grouping: On Android, use groupKey to stack related notifications. On iOS, use threadIdentifier.

Q4. Design the caching layer for an app that shows news articles with images

What the interviewer is REALLY testing:
Whether you understand multi-level caching (memory, disk), cache invalidation strategies, stale-while-revalidate pattern, image caching specifics, and storage budget management.

Answer:

Three-level caching architecture:

┌────────────────────────────────────────┐
│  Level 1: In-Memory Cache              │
│  - Hot data (current screen articles)  │
│  - LRU eviction, max 50 articles       │
│  - Zero latency                        │
├────────────────────────────────────────┤
│  Level 2: Structured Disk Cache        │
│  - Local database (Drift/Isar)         │
│  - Full article data + metadata        │
│  - TTL per content type                │
│  - Survives app restart                │
├────────────────────────────────────────┤
│  Level 3: HTTP Disk Cache              │
│  - dio_cache_interceptor or            │
│    flutter_cache_manager               │
│  - Raw HTTP responses                  │
│  - Respects Cache-Control headers      │
│  - For API responses + images          │
├────────────────────────────────────────┤
│  Origin: Remote API                    │
└────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Implementation:

class ArticleCacheManager {
  final AppDatabase _db;        // Level 2: structured disk
  final MemoryCache _memory;    // Level 1: in-memory
  final ApiClient _api;

  /// Stale-while-revalidate: return cached immediately, refresh in background
  Stream<CacheResult<List<Article>>> getArticles(String category) async* {
    // 1. Check memory cache
    final memoryHit = _memory.get<List<Article>>('articles_$category');
    if (memoryHit != null) {
      yield CacheResult(data: memoryHit, source: CacheSource.memory);
    }

    // 2. Check disk cache
    final diskHit = await _db.articlesDao.getByCategory(category);
    final cacheAge = await _db.cacheMetaDao.getAge('articles_$category');

    if (diskHit.isNotEmpty) {
      _memory.put('articles_$category', diskHit); // promote to L1
      yield CacheResult(data: diskHit, source: CacheSource.disk);

      // If cache is fresh enough, don't hit network
      if (cacheAge != null && cacheAge < const Duration(minutes: 5)) {
        return;
      }
    }

    // 3. Fetch from network (background refresh)
    try {
      final fresh = await _api.getArticles(category);
      // Update all cache levels
      _memory.put('articles_$category', fresh);
      await _db.articlesDao.upsertAll(fresh);
      await _db.cacheMetaDao.updateTimestamp('articles_$category');

      yield CacheResult(data: fresh, source: CacheSource.network);
    } catch (e) {
      // Network failed -- if we already yielded cached data, that's fine
      if (diskHit.isEmpty) {
        yield CacheResult.error(e);
      }
    }
  }
}

class CacheResult<T> {
  final T? data;
  final CacheSource? source;
  final Object? error;

  CacheResult({this.data, this.source, this.error});
  CacheResult.error(this.error) : data = null, source = null;

  bool get isFromCache =>
      source == CacheSource.memory || source == CacheSource.disk;
}

enum CacheSource { memory, disk, network }
Enter fullscreen mode Exit fullscreen mode

Image caching strategy:

class ImageCacheConfig {
  static void configure() {
    // CachedNetworkImage configuration
    // Max cache size: 500MB on disk, 100MB in memory
    // Max file age: 30 days for article images
    // Max stale: 7 days (serve stale while revalidating)
  }

  static Widget articleImage(String url, {double? width, double? height}) {
    return CachedNetworkImage(
      imageUrl: url,
      width: width,
      height: height,
      fit: BoxFit.cover,
      // Placeholder shows immediately from cache or a shimmer
      placeholder: (context, url) => Shimmer.fromColors(
        baseColor: Colors.grey[300]!,
        highlightColor: Colors.grey[100]!,
        child: Container(color: Colors.white),
      ),
      // Error widget if image fails to load and isn't cached
      errorWidget: (context, url, error) => const Icon(Icons.broken_image),
      // Memory cache: keep decoded images for instant display
      memCacheWidth: width?.toInt() ?? 400, // resize in memory to save RAM
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Cache size management:

class CacheHousekeeper {
  final AppDatabase _db;

  /// Run periodically (e.g., on app launch) to prevent unbounded growth
  Future<void> cleanup() async {
    // Delete articles older than 7 days
    await _db.articlesDao.deleteOlderThan(
      DateTime.now().subtract(const Duration(days: 7)),
    );

    // If DB is still too large, delete by LRU (least recently accessed)
    final dbSize = await _db.getDatabaseSize();
    if (dbSize > 100 * 1024 * 1024) { // 100MB threshold
      await _db.articlesDao.deleteLeastRecentlyAccessed(keepCount: 500);
    }

    // Clean image cache
    await DefaultCacheManager().emptyCache();
    // Or more targeted: remove files older than 30 days
  }
}
Enter fullscreen mode Exit fullscreen mode

Key trade-offs:

  • TTL per content type: Breaking news needs short TTL (2 min), opinion pieces can be cached longer (30 min), images can be cached for days.
  • Memory cache eviction: Use LRU with a max entry count, not max byte size. Byte-level tracking for Dart objects is impractical.
  • Offline reading: If you want offline reading as a feature, articles must be explicitly "saved" by the user, which downloads all images and stores the full article body in the DB with no TTL.

Q5. Design a multi-language app that supports RTL and dynamic language switching

What the interviewer is REALLY testing:
Whether you understand Flutter's localization system (intl, arb files, generated code), how RTL affects layout (not just text), dynamic locale switching without restart, and the challenges of pluralization, gender, and date/number formatting.

Answer:

Architecture:

┌───────────────────────────────────────────┐
│  l10n/                                     │
│  ├── app_en.arb          (English base)    │
│  ├── app_hi.arb          (Hindi)           │
│  ├── app_ar.arb          (Arabic - RTL)    │
│  ├── app_ur.arb          (Urdu - RTL)      │
│  └── l10n.yaml           (gen config)      │
├───────────────────────────────────────────┤
│  Generated: AppLocalizations               │
│  (via flutter gen-l10n)                    │
├───────────────────────────────────────────┤
│  LocaleNotifier (ChangeNotifier)           │
│  - Current locale                          │
│  - Persistence                             │
├───────────────────────────────────────────┤
│  MaterialApp                               │
│  - locale: from LocaleNotifier             │
│  - supportedLocales: all ARB locales       │
│  - localizationsDelegates: generated       │
└───────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

ARB file example with pluralization and placeholders:

// app_en.arb
{
  "@@locale": "en",
  "appTitle": "News Reader",
  "welcomeMessage": "Hello, {userName}!",
  "@welcomeMessage": {
    "placeholders": {
      "userName": { "type": "String" }
    }
  },
  "itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
  "@itemCount": {
    "placeholders": {
      "count": { "type": "int" }
    }
  },
  "lastUpdated": "Last updated {date}",
  "@lastUpdated": {
    "placeholders": {
      "date": { "type": "DateTime", "format": "yMMMd" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// app_ar.arb
{
  "@@locale": "ar",
  "appTitle": "قارئ الأخبار",
  "welcomeMessage": "مرحبا، {userName}!",
  "itemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}",
  "lastUpdated": "آخر تحديث {date}"
}
Enter fullscreen mode Exit fullscreen mode

Note: Arabic has SIX plural forms (zero, one, two, few, many, other). English has two (one, other). The intl package handles this automatically when you use ARB format.

Dynamic locale switching:

class LocaleNotifier extends ChangeNotifier {
  final SharedPreferences _prefs;
  Locale _locale;

  static const supportedLocales = [
    Locale('en'),
    Locale('hi'),
    Locale('ar'),
    Locale('ur'),
  ];

  LocaleNotifier(this._prefs) : _locale = _loadSavedLocale(_prefs);

  static Locale _loadSavedLocale(SharedPreferences prefs) {
    final saved = prefs.getString('locale');
    if (saved != null) {
      return Locale(saved);
    }
    // Fall back to system locale if supported, otherwise English
    final system = PlatformDispatcher.instance.locale;
    if (supportedLocales.any((l) => l.languageCode == system.languageCode)) {
      return Locale(system.languageCode);
    }
    return const Locale('en');
  }

  Locale get locale => _locale;
  bool get isRtl => _locale.languageCode == 'ar' || _locale.languageCode == 'ur';

  Future<void> setLocale(Locale locale) async {
    if (_locale == locale) return;
    _locale = locale;
    await _prefs.setString('locale', locale.languageCode);
    notifyListeners(); // rebuilds MaterialApp -> entire app re-localizes
  }
}
Enter fullscreen mode Exit fullscreen mode

MaterialApp wiring:

Consumer<LocaleNotifier>(
  builder: (context, localeNotifier, _) {
    return MaterialApp(
      locale: localeNotifier.locale,
      supportedLocales: LocaleNotifier.supportedLocales,
      localizationsDelegates: const [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      home: const HomeScreen(),
    );
  },
)
Enter fullscreen mode Exit fullscreen mode

RTL-aware layout (the part most developers get wrong):

// DON'T use left/right for padding/margin in an RTL app
// DO use start/end

// BAD:
Padding(padding: EdgeInsets.only(left: 16))

// GOOD:
Padding(padding: EdgeInsetsDirectional.only(start: 16))

// BAD:
Row(
  mainAxisAlignment: MainAxisAlignment.start, // means "left" -- wrong in RTL
)

// GOOD:
// Row start/end already respects directionality. The issue is with
// hardcoded Alignment.centerLeft, Positioned(left: 0), etc.

// BAD:
Positioned(left: 16, child: icon)

// GOOD:
PositionedDirectional(start: 16, child: icon)

// Icons that have directionality:
Icon(Icons.arrow_back) // Should flip in RTL
// Use:
Icon(Directionality.of(context) == TextDirection.rtl
    ? Icons.arrow_forward
    : Icons.arrow_back)
// Or better, use Icons.arrow_back which auto-mirrors in Material 3
Enter fullscreen mode Exit fullscreen mode

Key traps:

  • Not all icons should flip in RTL. A "play" button should always point right. A "back" arrow should flip.
  • Number formatting: Arabic has its own numeral system. Use NumberFormat from intl.
  • Some languages (like German) have much longer words than English, so your UI must handle text overflow gracefully.
  • Test with pseudolocalization: replace English strings with accented versions ("Hëllö") to find hardcoded strings.

Q6. Design the error handling and logging strategy for a production Flutter app

What the interviewer is REALLY testing:
Whether you understand the full error taxonomy (Dart exceptions, Flutter framework errors, platform errors, network errors), structured logging, crash reporting, and how to give users actionable feedback vs generic "something went wrong."

Answer:

Error handling architecture:

┌────────────────────────────────────────────────────┐
│  Zone-level: catches all uncaught async errors      │
├────────────────────────────────────────────────────┤
│  FlutterError.onError: catches framework errors     │
├────────────────────────────────────────────────────┤
│  PlatformDispatcher.onError: catches platform errors│
├────────────────────────────────────────────────────┤
│  Per-feature error boundaries (ErrorWidget)         │
├────────────────────────────────────────────────────┤
│  try/catch in business logic (expected errors)      │
└────────────────────────────────────────────────────┘
         │
         ▼
┌────────────────────────────────────────────────────┐
│  ErrorReportingService                              │
│  - Filters (don't report known/expected errors)     │
│  - Enriches (add user ID, screen, app version)     │
│  - Sends to: Crashlytics / Sentry / custom backend │
└────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Setup in main.dart:

void main() async {
  // Catch all errors in the root zone
  runZonedGuarded(
    () async {
      WidgetsFlutterBinding.ensureInitialized();

      // Catch Flutter framework errors (layout, build, paint)
      FlutterError.onError = (details) {
        FlutterError.presentError(details); // log to console in debug
        ErrorReportingService.instance.reportFlutterError(details);
      };

      // Catch platform dispatcher errors (platform channel failures, etc.)
      PlatformDispatcher.instance.onError = (error, stack) {
        ErrorReportingService.instance.reportError(error, stack);
        return true; // handled
      };

      // Replace the default error widget in release mode
      if (kReleaseMode) {
        ErrorWidget.builder = (details) => const _ProductionErrorWidget();
      }

      await _initializeApp();
      runApp(const MyApp());
    },
    (error, stack) {
      // Catches uncaught async errors
      ErrorReportingService.instance.reportError(error, stack);
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Error reporting service with filtering and enrichment:

class ErrorReportingService {
  static final instance = ErrorReportingService._();
  ErrorReportingService._();

  late final FirebaseCrashlytics _crashlytics;
  final _recentErrors = <String, DateTime>{}; // dedup

  Future<void> initialize(UserProfile? user) async {
    _crashlytics = FirebaseCrashlytics.instance;
    if (user != null) {
      _crashlytics.setUserIdentifier(user.id);
    }
    _crashlytics.setCustomKey('app_flavor', AppConfig.flavor);
    _crashlytics.setCustomKey('locale', Platform.localeName);
  }

  void reportError(Object error, StackTrace stack) {
    // Filter: don't report expected/common errors
    if (_shouldIgnore(error)) return;

    // Dedup: don't report the same error multiple times in quick succession
    final errorKey = '${error.runtimeType}_${stack.toString().hashCode}';
    final lastReport = _recentErrors[errorKey];
    if (lastReport != null &&
        DateTime.now().difference(lastReport) < const Duration(minutes: 1)) {
      return; // already reported this recently
    }
    _recentErrors[errorKey] = DateTime.now();

    // Log locally
    _localLogger.error('Unhandled error', error: error, stackTrace: stack);

    // Report to Crashlytics
    _crashlytics.recordError(error, stack, fatal: _isFatal(error));
  }

  void reportFlutterError(FlutterErrorDetails details) {
    _localLogger.error(
      'Flutter error: ${details.exceptionAsString()}',
      error: details.exception,
      stackTrace: details.stack,
    );
    _crashlytics.recordFlutterFatalError(details);
  }

  bool _shouldIgnore(Object error) {
    // Network errors when offline -- not a bug, just no connectivity
    if (error is SocketException) return true;
    // User cancelled an operation
    if (error is OperationCancelledException) return true;
    // HTTP 401 -- handled by auth interceptor
    if (error is DioException && error.response?.statusCode == 401) return true;
    return false;
  }

  bool _isFatal(Object error) {
    return error is OutOfMemoryError || error is StackOverflowError;
  }

  /// Add breadcrumbs for debugging (what was the user doing when it crashed?)
  void addBreadcrumb(String message, {Map<String, String>? data}) {
    _crashlytics.log(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Structured logging:

class AppLogger {
  static final _logger = Logger('App');

  static void init() {
    Logger.root.level = kReleaseMode ? Level.WARNING : Level.ALL;
    Logger.root.onRecord.listen((record) {
      // In debug: print to console with colors
      if (!kReleaseMode) {
        final color = switch (record.level) {
          Level.SEVERE => '\x1B[31m', // red
          Level.WARNING => '\x1B[33m', // yellow
          Level.INFO => '\x1B[32m', // green
          _ => '\x1B[37m', // white
        };
        debugPrint('$color[${record.level.name}] ${record.loggerName}: '
            '${record.message}\x1B[0m');
      }

      // In release: only write warnings and above to a local file
      // for user-exportable debug logs
      if (kReleaseMode && record.level >= Level.WARNING) {
        _writeToLogFile(record);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Per-feature error boundaries:

class ErrorBoundary extends StatefulWidget {
  final Widget child;
  final Widget Function(Object error, VoidCallback retry)? fallbackBuilder;

  const ErrorBoundary({required this.child, this.fallbackBuilder});

  @override
  State<ErrorBoundary> createState() => _ErrorBoundaryState();
}

class _ErrorBoundaryState extends State<ErrorBoundary> {
  Object? _error;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _error = null; // reset on dependency change (e.g., navigation)
  }

  void _retry() {
    setState(() => _error = null);
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) {
      return widget.fallbackBuilder?.call(_error!, _retry) ??
          Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Icon(Icons.error_outline, size: 48),
                const SizedBox(height: 16),
                const Text('Something went wrong'),
                TextButton(onPressed: _retry, child: const Text('Retry')),
              ],
            ),
          );
    }

    // Wrap child in a builder that catches build errors
    return _ErrorCatcher(
      onError: (error) => setState(() => _error = error),
      child: widget.child,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Q7. Design a CI/CD pipeline for a Flutter app with multiple flavors

What the interviewer is REALLY testing:
Whether you understand build flavors (dev/staging/prod), code signing, automated testing, artifact distribution, and the specific challenges of mobile CI/CD (emulators for integration tests, platform-specific build steps).

Answer:

Pipeline stages:

┌─────────┐   ┌──────────┐   ┌───────────┐   ┌──────────┐   ┌──────────┐
│  Trigger │──>│  Analyze  │──>│   Test    │──>│  Build   │──>│  Deploy  │
│          │   │  & Lint   │   │           │   │          │   │          │
│ PR/merge │   │ dart      │   │ unit      │   │ APK/AAB  │   │ Firebase │
│ tag      │   │ analyze   │   │ widget    │   │ IPA      │   │ App Dist │
│ manual   │   │ format    │   │ integr.   │   │ Web      │   │ TestFlgt │
│          │   │ custom    │   │ golden    │   │          │   │ Play/App │
│          │   │ lint rules│   │           │   │          │   │ Store    │
└─────────┘   └──────────┘   └───────────┘   └──────────┘   └──────────┘
Enter fullscreen mode Exit fullscreen mode

GitHub Actions example:

# .github/workflows/flutter_ci.yml
name: Flutter CI/CD

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]
    tags: ['v*']

env:
  FLUTTER_VERSION: '3.24.0'

jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}
          cache: true

      - run: flutter pub get
      - run: dart analyze --fatal-infos
      - run: dart format --set-exit-if-changed .
      - run: flutter pub run custom_lint # if using custom_lint

  test:
    runs-on: ubuntu-latest
    needs: analyze
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}
          cache: true

      - run: flutter pub get
      - run: flutter test --coverage
      - run: flutter test --update-goldens # golden tests

      # Upload coverage to Codecov
      - uses: codecov/codecov-action@v3
        with:
          file: coverage/lcov.info

  build-android:
    runs-on: ubuntu-latest
    needs: test
    strategy:
      matrix:
        flavor: [dev, staging, production]
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      # Decode signing key from secrets
      - name: Decode keystore
        run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/keystore.jks

      - name: Create key.properties
        run: |
          cat > android/key.properties << EOF
          storePassword=${{ secrets.KEYSTORE_PASSWORD }}
          keyPassword=${{ secrets.KEY_PASSWORD }}
          keyAlias=${{ secrets.KEY_ALIAS }}
          storeFile=keystore.jks
          EOF

      - run: flutter build appbundle --flavor ${{ matrix.flavor }} --dart-define=FLAVOR=${{ matrix.flavor }}

      - uses: actions/upload-artifact@v4
        with:
          name: android-${{ matrix.flavor }}
          path: build/app/outputs/bundle/${{ matrix.flavor }}Release/

  build-ios:
    runs-on: macos-latest
    needs: test
    strategy:
      matrix:
        flavor: [dev, staging, production]
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      # Install certificates and provisioning profiles
      - name: Install Apple Certificate
        uses: apple-actions/import-codesign-certs@v2
        with:
          p12-file-base64: ${{ secrets.IOS_CERTIFICATE_P12 }}
          p12-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}

      - name: Install Provisioning Profile
        run: |
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          echo "${{ secrets.IOS_PROVISIONING_PROFILE }}" | base64 -d > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision

      - run: flutter build ipa --flavor ${{ matrix.flavor }} --export-options-plist=ios/ExportOptions_${{ matrix.flavor }}.plist

      - uses: actions/upload-artifact@v4
        with:
          name: ios-${{ matrix.flavor }}
          path: build/ios/ipa/

  deploy-staging:
    runs-on: ubuntu-latest
    needs: [build-android, build-ios]
    if: github.ref == 'refs/heads/develop'
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: android-staging

      # Deploy to Firebase App Distribution
      - uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ secrets.FIREBASE_ANDROID_APP_ID_STAGING }}
          serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
          groups: qa-team
          file: app-staging-release.aab

  deploy-production:
    runs-on: ubuntu-latest
    needs: [build-android, build-ios]
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      # Deploy to Play Store (internal track)
      - uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT }}
          packageName: com.myapp.production
          releaseFiles: app-production-release.aab
          track: internal
Enter fullscreen mode Exit fullscreen mode

Flavor configuration in Flutter:

// lib/config/app_config.dart
enum Flavor { dev, staging, production }

class AppConfig {
  static late final Flavor flavor;
  static late final String apiBaseUrl;
  static late final String appName;

  static void initialize() {
    const flavorString = String.fromEnvironment('FLAVOR', defaultValue: 'dev');
    flavor = Flavor.values.byName(flavorString);

    switch (flavor) {
      case Flavor.dev:
        apiBaseUrl = 'https://dev-api.myapp.com';
        appName = 'MyApp DEV';
      case Flavor.staging:
        apiBaseUrl = 'https://staging-api.myapp.com';
        appName = 'MyApp STG';
      case Flavor.production:
        apiBaseUrl = 'https://api.myapp.com';
        appName = 'MyApp';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key considerations:

  • Caching: Cache pub get and Gradle dependencies to speed up builds.
  • Parallelism: Android and iOS builds are independent -- run them in parallel.
  • Secrets management: NEVER commit signing keys. Use CI/CD secrets with base64 encoding.
  • Version bumping: Use cider or a script to auto-bump version from git tags.
  • Integration tests: Run on a real emulator/simulator in CI. Use Firebase Test Lab for broader device coverage.

Q8. Design an analytics system that tracks user behavior across screens

What the interviewer is REALLY testing:
Whether you understand automatic screen tracking vs manual event tracking, the analytics funnel concept, user properties vs event properties, privacy regulations (GDPR consent), and how to implement analytics without polluting business logic.

Answer:

Architecture -- separate analytics from business logic:

/// Abstract analytics interface -- swap implementations without touching features
abstract class AnalyticsService {
  void trackScreen(String screenName, {Map<String, dynamic>? params});
  void trackEvent(String eventName, {Map<String, dynamic>? params});
  void setUserProperties(Map<String, dynamic> properties);
  void setUserId(String? userId);
  void startTimedEvent(String eventName);
  void endTimedEvent(String eventName, {Map<String, dynamic>? params});
}

/// Composite: send to multiple analytics providers simultaneously
class CompositeAnalytics implements AnalyticsService {
  final List<AnalyticsService> _providers;

  CompositeAnalytics(this._providers);

  @override
  void trackEvent(String eventName, {Map<String, dynamic>? params}) {
    for (final provider in _providers) {
      provider.trackEvent(eventName, params: params);
    }
  }
  // ... delegate all methods similarly
}

/// Concrete implementations
class FirebaseAnalyticsService implements AnalyticsService {
  final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;

  @override
  void trackEvent(String eventName, {Map<String, dynamic>? params}) {
    _analytics.logEvent(name: eventName, parameters: params);
  }
  // ...
}

class MixpanelAnalyticsService implements AnalyticsService {
  late final Mixpanel _mixpanel;
  // ...
}

// Setup
final analytics = CompositeAnalytics([
  FirebaseAnalyticsService(),
  MixpanelAnalyticsService(),
  if (!kReleaseMode) DebugAnalyticsService(), // prints to console in debug
]);
Enter fullscreen mode Exit fullscreen mode

Automatic screen tracking with NavigatorObserver:

class AnalyticsNavigatorObserver extends NavigatorObserver {
  final AnalyticsService _analytics;

  AnalyticsNavigatorObserver(this._analytics);

  @override
  void didPush(Route route, Route? previousRoute) {
    _trackScreen(route);
  }

  @override
  void didPop(Route route, Route? previousRoute) {
    if (previousRoute != null) _trackScreen(previousRoute);
  }

  @override
  void didReplace({Route? newRoute, Route? oldRoute}) {
    if (newRoute != null) _trackScreen(newRoute);
  }

  void _trackScreen(Route route) {
    final screenName = route.settings.name;
    if (screenName != null && screenName.isNotEmpty) {
      _analytics.trackScreen(screenName, params: {
        'route_args': route.settings.arguments?.toString(),
      });
    }
  }
}

// Attach to navigator
MaterialApp(
  navigatorObservers: [AnalyticsNavigatorObserver(analytics)],
)
Enter fullscreen mode Exit fullscreen mode

Event tracking without polluting business logic -- use mixins or aspect-oriented approach:

/// Analytics mixin for blocs
mixin AnalyticsTracking on Bloc<dynamic, dynamic> {
  AnalyticsService get analytics;

  void trackAction(String action, {Map<String, dynamic>? params}) {
    analytics.trackEvent(action, params: {
      'bloc': runtimeType.toString(),
      ...?params,
    });
  }
}

class CartBloc extends Bloc<CartEvent, CartState> with AnalyticsTracking {
  @override
  final AnalyticsService analytics;

  CartBloc(this.analytics) : super(CartInitial()) {
    on<AddToCart>((event, emit) {
      // Business logic
      final newState = _addItem(event.item);
      emit(newState);

      // Analytics (separate concern)
      trackAction('add_to_cart', params: {
        'item_id': event.item.id,
        'item_price': event.item.price,
        'cart_total': newState.total,
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

GDPR/privacy-aware analytics:

class ConsentAwareAnalytics implements AnalyticsService {
  final AnalyticsService _delegate;
  final ConsentManager _consent;

  ConsentAwareAnalytics(this._delegate, this._consent);

  @override
  void trackEvent(String eventName, {Map<String, dynamic>? params}) {
    if (_consent.hasAnalyticsConsent) {
      _delegate.trackEvent(eventName, params: params);
    }
  }

  @override
  void setUserId(String? userId) {
    if (_consent.hasAnalyticsConsent) {
      _delegate.setUserId(userId);
    } else {
      _delegate.setUserId(null); // anonymize
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Funnel tracking for critical flows:

class CheckoutFunnel {
  final AnalyticsService _analytics;

  void viewCart() => _analytics.trackEvent('checkout_funnel', params: {
    'step': 'view_cart',
    'step_number': 1,
  });

  void enterAddress() => _analytics.trackEvent('checkout_funnel', params: {
    'step': 'enter_address',
    'step_number': 2,
  });

  void selectPayment() => _analytics.trackEvent('checkout_funnel', params: {
    'step': 'select_payment',
    'step_number': 3,
  });

  void confirmOrder(double amount) => _analytics.trackEvent('checkout_funnel', params: {
    'step': 'confirm_order',
    'step_number': 4,
    'amount': amount,
  });

  void orderSuccess(String orderId) => _analytics.trackEvent('checkout_funnel', params: {
    'step': 'order_success',
    'step_number': 5,
    'order_id': orderId,
  });
}
Enter fullscreen mode Exit fullscreen mode

Q9. Design the state management strategy for an e-commerce app with cart, wishlist, orders

What the interviewer is REALLY testing:
Whether you can choose the RIGHT granularity of state management (not everything needs global state), handle inter-feature communication, and design for the complex data flows in e-commerce (cart synced with server, optimistic updates, price changes).

Answer:

State categorization:

State Scope Persistence Strategy
Auth/User Global Secure storage Riverpod/Provider at app root
Cart Global Local DB + server sync Dedicated CartNotifier
Wishlist Global Server (not critical offline) Dedicated WishlistNotifier
Product list Per-screen Memory cache FutureProvider / per-page state
Product detail Per-screen None Local StatefulWidget or per-page state
Search results Per-screen None Ephemeral, disposed on pop
Order history Per-screen Cache with refresh Per-screen with cache layer
Filters/sort Per-screen SharedPreferences Local state lifted to parent
Address book Global (shared by cart, profile) Server Dedicated notifier
Checkout flow Per-flow (multi-step) Memory only Step-based controller

The hard problem -- cart synchronization:

class CartService {
  final CartLocalDataSource _local;
  final CartRemoteDataSource _remote;
  final AuthService _auth;
  final _cartNotifier = ValueNotifier<Cart>(Cart.empty());

  ValueListenable<Cart> get cart => _cartNotifier;

  Future<void> initialize() async {
    // Load local cart first (instant)
    _cartNotifier.value = await _local.getCart();

    // If user is logged in, merge with server cart
    if (_auth.isLoggedIn) {
      await _syncWithServer();
    }
  }

  Future<void> addItem(Product product, int quantity) async {
    // Optimistic update
    final previousCart = _cartNotifier.value;
    _cartNotifier.value = previousCart.addItem(product, quantity);
    await _local.saveCart(_cartNotifier.value);

    // Sync with server
    if (_auth.isLoggedIn) {
      try {
        final serverCart = await _remote.addItem(product.id, quantity);
        _cartNotifier.value = serverCart; // server is source of truth (prices may differ)
        await _local.saveCart(serverCart);
      } catch (e) {
        // Revert optimistic update on failure
        _cartNotifier.value = previousCart;
        await _local.saveCart(previousCart);
        rethrow;
      }
    }
  }

  Future<void> _syncWithServer() async {
    try {
      final localCart = await _local.getCart();
      final serverCart = await _remote.getCart();

      if (localCart.isEmpty) {
        // User had no local cart -- use server
        _cartNotifier.value = serverCart;
      } else if (serverCart.isEmpty) {
        // Server had no cart -- push local to server
        await _remote.replaceCart(localCart);
        _cartNotifier.value = localCart;
      } else {
        // Both have items -- merge (server prices win, quantities merge)
        final merged = _mergeCart(localCart, serverCart);
        await _remote.replaceCart(merged);
        _cartNotifier.value = merged;
      }

      await _local.saveCart(_cartNotifier.value);
    } catch (e) {
      // Offline -- use local cart
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Inter-feature communication (cart <-> wishlist):

// Moving item from wishlist to cart
class WishlistNotifier extends ChangeNotifier {
  final CartService _cartService;

  Future<void> moveToCart(Product product) async {
    await _cartService.addItem(product, 1);
    _items.removeWhere((item) => item.id == product.id);
    await _remote.removeFromWishlist(product.id);
    notifyListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

Dependency graph:

AuthNotifier (root)
  │
  ├── CartService (depends on Auth for sync decisions)
  │     │
  │     └── CheckoutController (depends on Cart)
  │           │
  │           ├── AddressNotifier (depends on Auth for saved addresses)
  │           └── PaymentService
  │
  ├── WishlistNotifier (depends on Auth, CartService)
  │
  └── OrderHistoryNotifier (depends on Auth)
Enter fullscreen mode Exit fullscreen mode

With Riverpod, this becomes explicit:

final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);

final cartProvider = NotifierProvider<CartNotifier, Cart>((ref) {
  final auth = ref.watch(authProvider);
  return CartNotifier(auth);
});

final wishlistProvider = NotifierProvider<WishlistNotifier, List<Product>>((ref) {
  final auth = ref.watch(authProvider);
  final cart = ref.read(cartProvider.notifier); // read, not watch
  return WishlistNotifier(auth, cart);
});

final checkoutProvider = NotifierProvider<CheckoutNotifier, CheckoutState>((ref) {
  final cart = ref.watch(cartProvider);
  return CheckoutNotifier(cart);
});
Enter fullscreen mode Exit fullscreen mode

Key insight: The cart is the most complex state in an e-commerce app because it spans the guest-to-authenticated boundary (merge carts on login), involves server-side price validation, and must be accessible from almost every screen. It deserves its own dedicated service, not just a simple StateNotifier.


Q10. Design a plugin architecture -- your app should support third-party extensions

What the interviewer is REALLY testing:
Whether you understand plugin interfaces, dynamic registration, sandboxing concerns, and how to design stable APIs that third parties can build against without breaking when you update the host app.

Answer:

Use case: An app like Shopify POS, Jira, or Slack where third-party developers can add tabs, actions, or integrations.

Architecture:

┌──────────────────────────────────────────────┐
│  Host App                                     │
│  ├── PluginRegistry (discovers & loads)       │
│  ├── PluginHost (provides APIs to plugins)    │
│  ├── PluginSandbox (limits what plugins can do)│
│  └── PluginUI (renders plugin-provided widgets)│
├──────────────────────────────────────────────┤
│  Plugin SDK (published as a package)          │
│  ├── PluginInterface (abstract contract)      │
│  ├── HostAPI (what the host exposes)          │
│  └── UI Components (design system subset)     │
├──────────────────────────────────────────────┤
│  Plugins (separate packages / remote config)  │
│  ├── Plugin A (compiled in)                   │
│  ├── Plugin B (compiled in)                   │
│  └── Plugin C (server-driven UI)              │
└──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Plugin interface (the contract):

// Published as `my_app_plugin_sdk` package

/// Every plugin must implement this
abstract class AppPlugin {
  /// Unique identifier
  String get id;

  /// Human-readable name
  String get name;

  /// Semantic version of the plugin
  String get version;

  /// Minimum host app version this plugin supports
  String get minHostVersion;

  /// Called when the plugin is loaded. Return false to indicate failure.
  Future<bool> initialize(HostAPI hostApi);

  /// Extension points the plugin provides
  List<ExtensionPoint> get extensions;

  /// Called when the plugin is unloaded
  Future<void> dispose();
}

/// What the host app exposes to plugins
abstract class HostAPI {
  /// Read current user info (limited, no password/tokens)
  UserSummary get currentUser;

  /// Navigate to a screen
  void navigateTo(String route, {Map<String, dynamic>? params});

  /// Show a toast/snackbar
  void showMessage(String message, {MessageType type});

  /// Make an API call through the host's authenticated client
  Future<Map<String, dynamic>> apiCall(String endpoint, {
    String method = 'GET',
    Map<String, dynamic>? body,
  });

  /// Access to a sandboxed key-value store for this plugin
  PluginStorage get storage;

  /// Register a listener for host events
  void on(String eventName, void Function(Map<String, dynamic> data) handler);
}

/// Defines where a plugin injects its UI/functionality
sealed class ExtensionPoint {}

class TabExtension extends ExtensionPoint {
  final String label;
  final IconData icon;
  final Widget Function(BuildContext context) builder;
  TabExtension({required this.label, required this.icon, required this.builder});
}

class ActionExtension extends ExtensionPoint {
  final String label;
  final String targetScreen; // which screen this action appears on
  final Future<void> Function(BuildContext context, Map<String, dynamic> contextData) onAction;
  ActionExtension({required this.label, required this.targetScreen, required this.onAction});
}

class SettingsExtension extends ExtensionPoint {
  final Widget Function(BuildContext context) builder;
  SettingsExtension({required this.builder});
}
Enter fullscreen mode Exit fullscreen mode

Plugin registry and loading:

class PluginRegistry {
  final HostAPI _hostApi;
  final List<AppPlugin> _loadedPlugins = [];
  final Map<String, List<ExtensionPoint>> _extensionsByType = {};

  /// Register all plugins (called at app startup)
  Future<void> loadPlugins(List<AppPlugin> plugins) async {
    for (final plugin in plugins) {
      try {
        // Version compatibility check
        if (!_isCompatible(plugin.minHostVersion)) {
          debugPrint('Plugin ${plugin.id} requires host ${plugin.minHostVersion}, skipping');
          continue;
        }

        final success = await plugin.initialize(_hostApi);
        if (success) {
          _loadedPlugins.add(plugin);
          for (final ext in plugin.extensions) {
            final type = ext.runtimeType.toString();
            _extensionsByType.putIfAbsent(type, () => []).add(ext);
          }
        }
      } catch (e) {
        // Plugin failed to load -- log but don't crash the app
        debugPrint('Plugin ${plugin.id} failed to load: $e');
      }
    }
  }

  /// Get all tab extensions (used by the main scaffold)
  List<TabExtension> get tabExtensions =>
      _extensionsByType['TabExtension']?.cast<TabExtension>() ?? [];

  /// Get action extensions for a specific screen
  List<ActionExtension> getActionsForScreen(String screenName) =>
      (_extensionsByType['ActionExtension']?.cast<ActionExtension>() ?? [])
          .where((a) => a.targetScreen == screenName)
          .toList();

  Future<void> disposeAll() async {
    for (final plugin in _loadedPlugins) {
      await plugin.dispose();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Host app integration:

class MainScaffold extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final registry = context.read<PluginRegistry>();
    final pluginTabs = registry.tabExtensions;

    // Core tabs + plugin tabs
    final allTabs = [
      const BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
      const BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
      ...pluginTabs.map((t) => BottomNavigationBarItem(
        icon: Icon(t.icon),
        label: t.label,
      )),
      const BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
    ];

    return Scaffold(
      body: IndexedStack(
        index: _currentIndex,
        children: [
          const HomeScreen(),
          const SearchScreen(),
          // Plugin screens wrapped in error boundary
          ...pluginTabs.map((t) => ErrorBoundary(
            child: t.builder(context),
          )),
          const ProfileScreen(),
        ],
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: allTabs,
        currentIndex: _currentIndex,
        onTap: (i) => setState(() => _currentIndex = i),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Security considerations:

  • Plugins should NOT have direct access to the auth token, database, or file system.
  • The HostAPI.apiCall method should prefix all plugin API calls with /plugins/{pluginId}/ so the server can scope access.
  • PluginStorage should be namespaced per plugin (plugins cannot read other plugins' data).
  • Wrap all plugin-provided widgets in ErrorBoundary so a crashing plugin doesn't take down the app.
  • Consider a permissions model: plugins declare what they need, the user approves.

Alternative: Server-driven UI for plugins (no compiled-in code):

For truly dynamic plugins (added without app update), use a server-driven UI approach where the plugin's UI is described in JSON and rendered by a generic widget renderer on the client. This is how Shopify and Slack handle third-party integrations on mobile.


Top comments (0)