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:
- 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.
- Pull-to-refresh must reset pagination -- not append to existing data.
- 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();
}
}
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();
}
}
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?" --
PageStorageKeyor 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();
}
}
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();
}
}
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
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;
}
}
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'),
),
};
}
}
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,
NSPhotoLibraryUsageDescriptionandNSCameraUsageDescriptionmust 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 ------| |
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);
}
}
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();
}
}
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);
}
}
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)!,
);
}
}
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,
);
}
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();
}
}
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],
),
),
);
}
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 │ │
└──────────────────────┴──────────────────────────────┘
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),
);
},
)
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});
}
}
}
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);
}
}
}
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);
}
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:
- User logs in with username/password, server returns an auth token.
- App asks "Enable biometric login?" If yes, store the token in secure storage (Keychain on iOS, Keystore on Android).
- 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');
}
}
Platform-specific configuration:
Android MainActivity.kt:
// Use FragmentActivity, not FlutterActivity
class MainActivity: FlutterFragmentActivity()
Android AndroidManifest.xml:
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
iOS Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to securely sign you in</string>
Security considerations:
- On Android,
FlutterSecureStorageuses 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: trueoption 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();
}
}
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,
),
),
);
},
);
}
}
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
}
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());
}
}
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;
}
}
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();
}
}
Battery considerations:
- Use
distanceFilterto avoid processing every GPS tick. - Switch to
LocationAccuracy.lowwhen 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 │
└───────────────────────┘
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
}
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;
}
}
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));
}
}
}
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;
}
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'),
),
],
);
},
);
}
}
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,
});
}
}
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'),
),
],
),
],
),
),
],
),
),
);
},
);
}
}
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();
}
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));
}
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});
}
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!;
},
);
}
}
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');
},
),
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);
}
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();
}
}
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(),
);
},
);
}
}
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 │
└─────────────────────────────────────────────────────────┘
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
}
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(),
};
}
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;
}
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 │
└──────────────────────────────────────────────────┘
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),
),
);
},
),
),
],
);
}
}
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),
};
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);
}
}
}
}
}
}
}
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,
),
),
),
),
),
),
],
),
);
}
}
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
libsignalprotocol). - 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) │ │ │ │ │
└─────────────┘ └──────────────┘ └─────────────┘ └──────────┘
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
}
}
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,
});
}
}
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');
}
}
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
groupKeyto stack related notifications. On iOS, usethreadIdentifier.
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 │
└────────────────────────────────────────┘
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 }
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
);
}
}
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
}
}
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 │
└───────────────────────────────────────────┘
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" }
}
}
}
// app_ar.arb
{
"@@locale": "ar",
"appTitle": "قارئ الأخبار",
"welcomeMessage": "مرحبا، {userName}!",
"itemCount": "{count, plural, =0{لا عناصر} =1{عنصر واحد} =2{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}",
"lastUpdated": "آخر تحديث {date}"
}
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
}
}
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(),
);
},
)
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
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
NumberFormatfromintl. - 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 │
└────────────────────────────────────────────────────┘
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);
},
);
}
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);
}
}
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);
}
});
}
}
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,
);
}
}
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 │
└─────────┘ └──────────┘ └───────────┘ └──────────┘ └──────────┘
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
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';
}
}
}
Key considerations:
-
Caching: Cache
pub getand 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
cideror 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
]);
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)],
)
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,
});
});
}
}
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
}
}
}
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,
});
}
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
}
}
}
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();
}
}
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)
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);
});
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) │
└──────────────────────────────────────────────┘
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});
}
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();
}
}
}
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),
),
);
}
}
Security considerations:
- Plugins should NOT have direct access to the auth token, database, or file system.
- The
HostAPI.apiCallmethod should prefix all plugin API calls with/plugins/{pluginId}/so the server can scope access. -
PluginStorageshould be namespaced per plugin (plugins cannot read other plugins' data). - Wrap all plugin-provided widgets in
ErrorBoundaryso 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)