Flutter Coding Interview Questions and Answers
100 hand-picked Flutter Coding interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
Build a counter app using Provider and ChangeNotifier.
Create a ChangeNotifier model that holds the count and calls notifyListeners() on change. Expose it via ChangeNotifierProvider at the top of the tree, and read/update it in widgets with context.watch<CounterModel>() (rebuilds) or context.read<CounterModel>() (fire-and-forget calls).
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() { _count++; notifyListeners(); }
}
// UI
Text('${context.watch<CounterModel>().count}');
ElevatedButton(
onPressed: () => context.read<CounterModel>().increment(),
child: Text('+'),
);
Build a simple todo list with add, toggle-complete, and delete.
Keep a List<Todo> in a StatefulWidget. Adding/removing/toggling all call setState to mutate the list and trigger a rebuild. Render with ListView.builder and use each item's id as a ValueKey so Flutter tracks identity correctly across reorders/deletes.
List<Todo> todos = [];
void add(String title) => setState(() => todos.add(Todo(title)));
void toggle(Todo t) => setState(() => t.done = !t.done);
void remove(Todo t) => setState(() => todos.remove(t));
Implement a debounced search field in Flutter.
Listen to the TextEditingController, cancel any pending Timer on each keystroke, and start a new short-delay timer that fires the actual API call—so a network request only fires after the user pauses typing.
Timer? _debounce;
void onChanged(String query) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
searchApi(query);
});
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
Implement infinite scroll pagination in a ListView.
Attach a ScrollController to the ListView and listen for the scroll position nearing maxScrollExtent; when close enough, fetch the next page and append it to the data list with setState. Guard with an isLoading flag to avoid firing duplicate requests.
final controller = ScrollController();
bool isLoading = false;
@override
void initState() {
super.initState();
controller.addListener(() {
if (controller.position.pixels >=
controller.position.maxScrollExtent - 200 && !isLoading) {
loadMore();
}
});
}
Fetch and render API data using FutureBuilder.
Store the Future in initState (not in build, or it re-fetches on every rebuild), then pass it to FutureBuilder and branch on snapshot.connectionState and snapshot.hasError to render loading, error, or data states.
late Future<List<User>> usersFuture;
@override
void initState() {
super.initState();
usersFuture = api.fetchUsers();
}
FutureBuilder<List<User>>(
future: usersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
return ListView(children: [for (final u in snapshot.data!) Text(u.name)]);
},
);
Build a live countdown timer using Stream and StreamBuilder.
Use Stream.periodic to emit a decreasing value every second, take() to limit it, and feed it to a StreamBuilder so the UI updates automatically each tick without manual setState calls.
Stream<int> countdown(int from) => Stream.periodic(
const Duration(seconds: 1), (i) => from - i - 1,
).take(from);
StreamBuilder<int>(
stream: countdown(10),
builder: (context, snapshot) => Text('${snapshot.data ?? 10}'),
);
Build a login form with email/password validation.
Wrap fields in a Form with a GlobalKey<FormState>. Each TextFormField validates its own input (email format, min password length); on submit, call formKey.currentState!.validate() and only proceed if it returns true.
final formKey = GlobalKey<FormState>();
Form(
key: formKey,
child: Column(children: [
TextFormField(
validator: (v) => v != null && v.contains('@') ? null : 'Invalid email',
),
TextFormField(
obscureText: true,
validator: (v) => (v?.length ?? 0) >= 6 ? null : 'Min 6 characters',
),
]),
);
Build a reusable custom button widget with a loading state.
Wrap the design in a StatelessWidget exposing label, onPressed, and isLoading as parameters. When isLoading is true, disable the button (pass null as the callback) and swap the label for a small CircularProgressIndicator.
class AppButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final bool isLoading;
const AppButton({super.key, required this.label, this.onPressed, this.isLoading = false});
@override
Widget build(BuildContext context) => ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: Text(label),
);
}
Build a bottom navigation bar that preserves each tab's state.
Use BottomNavigationBar to switch a selected index, but render all tab pages inside an IndexedStack instead of swapping widgets directly—IndexedStack keeps every child alive in the tree (just hidden), preserving scroll position and state when switching tabs.
int index = 0;
final pages = [HomePage(), SearchPage(), ProfilePage()];
Scaffold(
body: IndexedStack(index: index, children: pages),
bottomNavigationBar: BottomNavigationBar(
currentIndex: index,
onTap: (i) => setState(() => index = i),
items: const [],
),
);
Build a layout that switches between mobile and tablet designs.
Use LayoutBuilder to read the available width from constraints.maxWidth and branch the widget tree at a breakpoint (e.g. 600px)—rendering a single column on narrow screens and a two-pane layout on wider ones.
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
return const MobileLayout();
}
return const TabletLayout();
},
);
Implement a counter using the Bloc/Cubit pattern.
Define a Cubit<int> exposing increment()/decrement() methods that call emit() with the new state. Provide it with BlocProvider and rebuild the UI with BlocBuilder<CounterCubit, int>.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}
BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text('$count'),
);
Add an auth-token interceptor with automatic refresh using Dio.
Register an InterceptorsWrapper that attaches the current access token to every outgoing request in onRequest. In onError, check for a 401, call the refresh-token endpoint, update stored tokens, and retry the original request via handler.resolve(await dio.fetch(options)).
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer $accessToken';
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
accessToken = await refreshToken();
return handler.resolve(await dio.fetch(error.requestOptions));
}
handler.next(error);
},
));
Write a Dart function to check if a string is a palindrome.
Normalize the string (lowercase, strip non-alphanumeric characters if needed), then compare it to its reverse. This runs in O(n) time and O(n) space.
bool isPalindrome(String input) {
final s = input.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
return s == s.split('').reversed.join();
}
Write a Dart function to compute the nth Fibonacci number, and explain how to optimize the naive recursive version.
The naive recursive solution recomputes overlapping subproblems, giving O(2^n) time. Adding memoization (caching already-computed results in a Map) brings it down to O(n) time and O(n) space. An iterative version achieves O(n) time with O(1) space.
final _cache = <int, int>{};
int fib(int n) {
if (n <= 1) return n;
return _cache[n] ??= fib(n - 1) + fib(n - 2);
}
Write a Dart function to flatten an arbitrarily nested list.
Recursively walk each element: if it's itself a List, recurse into it and spread the flattened result; otherwise add it directly to the output. This handles any depth of nesting.
List<dynamic> flatten(List<dynamic> input) {
final result = <dynamic>[];
for (final item in input) {
if (item is List) {
result.addAll(flatten(item));
} else {
result.add(item);
}
}
return result;
}
Implement a generic debounce function from scratch in Dart.
A debounce wraps a callback so repeated calls within a delay window cancel the pending invocation and restart the timer—only the last call within the quiet period actually executes. Implemented with a closure holding a single Timer? across calls.
void Function() debounce(void Function() action, Duration delay) {
Timer? timer;
return () {
timer?.cancel();
timer = Timer(delay, action);
};
}
final debouncedSave = debounce(() => save(), const Duration(milliseconds: 500));
Solve the Two Sum problem in Dart.
Iterate through the list once, tracking each value's index in a Map. For each element, check whether target - element already exists in the map—if so, return both indices. This runs in O(n) time and O(n) space, versus O(n²) for the brute-force nested loop.
List<int> twoSum(List<int> nums, int target) {
final seen = <int, int>{};
for (var i = 0; i < nums.length; i++) {
final complement = target - nums[i];
if (seen.containsKey(complement)) return [seen[complement]!, i];
seen[nums[i]] = i;
}
throw ArgumentError('No solution found');
}
Build a stopwatch with start, pause, and reset.
Use a Timer.periodic (e.g. every 30-50ms) to increment an elapsed-time counter and call setState. Start creates the timer, pause cancels it while keeping the elapsed value, and reset cancels the timer and zeroes the counter.
Timer? _timer;
Duration _elapsed = Duration.zero;
void start() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 30), (_) {
setState(() => _elapsed += const Duration(milliseconds: 30));
});
}
void pause() => _timer?.cancel();
void reset() { _timer?.cancel(); setState(() => _elapsed = Duration.zero); }
Build a quiz app that tracks the score across questions.
Keep a List<Question>, a currentIndex, and a score in state. Selecting an answer checks it against the correct answer, increments the score if correct, then advances currentIndex; when the index reaches the list length, show a results screen.
int currentIndex = 0;
int score = 0;
void answer(String selected) {
if (selected == questions[currentIndex].correctAnswer) score++;
setState(() => currentIndex++);
}
Build a shopping cart with add, remove, quantity adjustment, and a running total.
Model the cart as a Map<Product, int> (product to quantity) inside a ChangeNotifier. Adding increments the quantity (or inserts at 1), removing decrements/removes at 0, and a getter computes the total by summing price * quantity across entries.
class Cart extends ChangeNotifier {
final Map<Product, int> _items = {};
void add(Product p) { _items[p] = (_items[p] ?? 0) + 1; notifyListeners(); }
void remove(Product p) {
if ((_items[p] ?? 0) <= 1) { _items.remove(p); } else { _items[p] = _items[p]! - 1; }
notifyListeners();
}
double get total => _items.entries.fold(0, (sum, e) => sum + e.key.price * e.value);
}
Build a weather app screen that fetches and displays current conditions.
Fetch weather data in initState and store the Future; render with FutureBuilder, mapping the response to a model (temperature, condition, icon). Add a manual refresh button that re-assigns the Future and calls setState to re-trigger the FutureBuilder.
late Future<Weather> weatherFuture;
void refresh() => setState(() => weatherFuture = api.fetchWeather(city));
Build a basic chat screen UI (message list + input bar).
Render messages with ListView.builder(reverse: true) so the list stays pinned to the newest message at the bottom, feeding it a reversed data list (or just inserting new messages at index 0). Pair it with a bottom input row (TextField + send IconButton) wrapped in a SafeArea so it isn't hidden by the keyboard or home indicator.
ListView.builder(
reverse: true,
itemCount: messages.length,
itemBuilder: (context, i) => MessageBubble(message: messages[messages.length - 1 - i]),
);
Build an image gallery using GridView.builder.
GridView.builder with a SliverGridDelegateWithFixedCrossAxisCount (or ...WithMaxCrossAxisExtent for responsive column counts) lazily builds only the visible thumbnails, using Image.network with a placeholder/error builder for each cell.
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
itemCount: images.length,
itemBuilder: (context, i) => Image.network(images[i], fit: BoxFit.cover),
);
Build an expandable/collapsible FAQ list.
ExpansionTile handles the expand/collapse animation and state internally—just give each one a title (the question) and a children list (the answer). For a custom look, track expanded state per item manually with an AnimatedCrossFade or AnimatedSize.
ExpansionTile(
title: Text(faq.question),
children: [Padding(padding: const EdgeInsets.all(16), child: Text(faq.answer))],
);
Build a drag-to-reorder list.
ReorderableListView handles the drag gesture and animation automatically; you just implement onReorder(oldIndex, newIndex) to update your underlying list's order (adjusting the index since Flutter passes newIndex as if the item hadn't been removed yet). Each child needs a unique key.
ReorderableListView(
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
});
},
children: [for (final i in items) ListTile(key: ValueKey(i.id), title: Text(i.name))],
);
Build a reusable star-rating widget.
Render N icon widgets in a Row, filling stars up to the current rating value; wrap each in a GestureDetector that sets the rating to its index+1 on tap and calls a passed-in onChanged callback so the parent controls the actual state.
Row(children: List.generate(5, (i) => GestureDetector(
onTap: () => onChanged(i + 1),
child: Icon(i < rating ? Icons.star : Icons.star_border, color: Colors.amber),
)));
Build a search bar that filters a local list as the user types.
Keep the full source list and a filtered list in state. On each TextField change, filter the source list case-insensitively and call setState to update the displayed list—no debounce/API call needed since it's all in-memory.
void onSearch(String query) {
setState(() {
filtered = allItems.where((i) => i.name.toLowerCase().contains(query.toLowerCase())).toList();
});
}
Build a screen with a custom-styled TabBar and three tabs.
Use a DefaultTabController (or a manual TabController with a SingleTickerProviderStateMixin) with a TabBar in the AppBar's bottom slot, and a matching TabBarView in the body holding one widget per tab in the same order.
DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(bottom: const TabBar(tabs: [Tab(text: 'A'), Tab(text: 'B'), Tab(text: 'C')])),
body: const TabBarView(children: [PageA(), PageB(), PageC()]),
),
);
Build a draggable bottom sheet that expands from partial to full height.
DraggableScrollableSheet lets a bottom sheet be dragged between a min, initial, and max fractional size of the screen, with its own scroll controller so content scrolls once the sheet reaches max size instead of dragging further.
DraggableScrollableSheet(
initialChildSize: 0.4,
minChildSize: 0.2,
maxChildSize: 0.9,
builder: (context, scrollController) => ListView.builder(
controller: scrollController,
itemCount: items.length,
itemBuilder: (context, i) => ListTile(title: Text(items[i])),
),
);
Build a splash screen that navigates to home after a delay.
In initState, start a Future.delayed (or check an async auth-status call) and then call Navigator.pushReplacement so the splash screen isn't left in the back stack (preventing the user from navigating back to it).
@override
void initState() {
super.initState();
Future.delayed(const Duration(seconds: 2), () {
if (!mounted) return;
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const HomePage()));
});
}
Build an onboarding intro slider with page indicator dots.
Use a PageView.builder for the swipeable slides and listen to its PageController to track the current page index, rendering a row of dots that highlight the active index. A 'Next'/'Get Started' button on the last page navigates away.
final controller = PageController();
int currentPage = 0;
PageView.builder(
controller: controller,
onPageChanged: (i) => setState(() => currentPage = i),
itemCount: slides.length,
itemBuilder: (context, i) => OnboardSlide(slides[i]),
);
Build a light/dark theme switcher using Provider.
A ChangeNotifier holds a ThemeMode field and a toggle method that calls notifyListeners(). MaterialApp reads it with context.watch and passes it to its themeMode parameter, alongside separate theme and darkTheme definitions.
class ThemeController extends ChangeNotifier {
ThemeMode mode = ThemeMode.system;
void toggle() {
mode = mode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
notifyListeners();
}
}
Build a reusable snackbar utility function for success/error messages.
Wrap ScaffoldMessenger.of(context).showSnackBar in a static helper that takes a message and a type (success/error), picking a background color and icon accordingly—centralizing styling so every screen shows consistent feedback with one function call.
class AppSnackbar {
static void show(BuildContext context, String message, {bool isError = false}) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : Colors.green,
));
}
}
Build a widget that shows a banner when the device loses internet connectivity.
The connectivity_plus package exposes Connectivity().onConnectivityChanged as a Stream; feed it into a StreamBuilder at the app's root, and conditionally show a persistent banner ('No internet connection') when the result is ConnectivityResult.none.
StreamBuilder<List<ConnectivityResult>>(
stream: Connectivity().onConnectivityChanged,
builder: (context, snapshot) {
final offline = snapshot.data?.contains(ConnectivityResult.none) ?? false;
return offline ? const OfflineBanner() : const SizedBox.shrink();
},
);
Implement a biometric (fingerprint/Face ID) login gate using local_auth.
The local_auth package's authenticate() method triggers the native biometric prompt and returns a bool; check canCheckBiometrics first to confirm the device supports it, and always provide a fallback (PIN/password) path for devices or users without biometrics enrolled.
final auth = LocalAuthentication();
if (await auth.canCheckBiometrics) {
final didAuth = await auth.authenticate(
localizedReason: 'Please authenticate to continue',
);
}
Implement picking an image and uploading it to an API with progress tracking.
Pick the file with image_picker, then build a FormData with MultipartFile.fromFile and send it via Dio's post, passing an onSendProgress callback to update a progress indicator as bytes are uploaded.
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(image.path, filename: 'upload.jpg'),
});
await dio.post('/upload', data: formData, onSendProgress: (sent, total) {
setState(() => progress = sent / total);
});
Build a video player screen with play/pause controls.
Initialize a VideoPlayerController from a network/asset source in initState, await initialize(), then wrap the VideoPlayer widget with custom play/pause buttons calling controller.play()/pause(). Always dispose the controller.
late VideoPlayerController controller;
@override
void initState() {
super.initState();
controller = VideoPlayerController.networkUrl(Uri.parse(url))..initialize().then((_) => setState(() {}));
}
@override
void dispose() { controller.dispose(); super.dispose(); }
Build a custom circular progress indicator that shows a percentage in the center.
Stack a CircularProgressIndicator(value: progress) (or a CustomPainter drawing an arc with Canvas.drawArc for full styling control) with a centered Text('${(progress * 100).round()}%') using a Stack + Alignment.center.
Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress, strokeWidth: 8),
Text('${(progress * 100).round()}%'),
],
);
Implement the core logic for a basic calculator app (digits, operators, equals).
Keep the current input string, a pending operator, and a stored first operand in state. Digit taps append to the display string; an operator tap stores the current value and operator, clearing the display for the next number; equals applies the stored operator between the stored value and the current display value.
String display = '0';
double? stored;
String? pendingOp;
void onOperator(String op) {
stored = double.parse(display);
pendingOp = op;
display = '0';
}
void onEquals() {
final current = double.parse(display);
display = switch (pendingOp) {
'+' => (stored! + current).toString(),
'-' => (stored! - current).toString(),
_ => display,
};
}
Build a currency converter that fetches live exchange rates.
Fetch a rates map (base currency to rate) from an API once and cache it in state; conversion itself is then a pure local calculation (amount * rate) on every keystroke, avoiding an API call per digit typed. Refresh the rates periodically or via a manual refresh button.
double convert(double amount, double rate) => amount * rate;
Build a notes app that persists notes locally using Hive.
Register a Hive adapter for the Note model (or store as a Map for simplicity), open a Hive box at startup, and treat the box like a reactive local database—use ValueListenableBuilder on box.listenable() to automatically rebuild the UI whenever notes are added, edited, or deleted, without manual setState plumbing.
final box = await Hive.openBox('notes');
ValueListenableBuilder(
valueListenable: box.listenable(),
builder: (context, Box box, _) => ListView(
children: box.values.map((n) => ListTile(title: Text(n.title))).toList(),
),
);
Build a favorite/bookmark toggle that persists across app restarts.
Store favorited item IDs as a Set<String> (serialized to a List<String> for shared_preferences, since it can't store sets directly). Toggling adds/removes the ID from the set, persists it, and calls notifyListeners() so every screen showing that item reflects the change immediately.
Future<void> toggleFavorite(String id) async {
favorites.contains(id) ? favorites.remove(id) : favorites.add(id);
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('favorites', favorites.toList());
notifyListeners();
}
Build a multi-step form wizard (e.g. a 3-step signup flow).
Use a PageView with NeverScrollableScrollPhysics (so users can't swipe past validation) driven by a PageController; each step has its own Form/GlobalKey<FormState>, and 'Next' only advances the page if that step's form validates. Collect all step data into one shared model object passed down to each step.
void next() {
if (formKeys[currentStep].currentState!.validate()) {
pageController.nextPage(duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
}
Implement date selection using showDatePicker, and format the result.
showDatePicker returns a Future<DateTime?> that resolves to null if the user cancels. Store the selected date in state and format it for display using the intl package's DateFormat rather than manually concatenating date fields.
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) setState(() => selectedDate = picked);
Build a 6-digit OTP verification input with auto-focus advance.
Use 6 individual single-character TextFields, each with its own FocusNode and TextEditingController; on each field's onChanged, if a digit was entered, request focus on the next field's FocusNode (or the previous one on backspace/empty), and concatenate all controllers' text to validate the full code.
void onChanged(int index, String value) {
if (value.isNotEmpty && index < 5) {
focusNodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
focusNodes[index - 1].requestFocus();
}
}
Implement Google Sign-In with Firebase Auth.
Trigger the native Google sign-in flow via the google_sign_in package to get an auth token, then exchange it for a Firebase credential with GoogleAuthProvider.credential(...) and sign in to Firebase via FirebaseAuth.instance.signInWithCredential(...), giving you a unified Firebase user regardless of provider.
final googleUser = await GoogleSignIn().signIn();
final googleAuth = await googleUser!.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await FirebaseAuth.instance.signInWithCredential(credential);
Build a list with section headers that stick to the top while scrolling.
Use a CustomScrollView with one SliverPersistentHeader(pinned: true) per section (implementing a SliverPersistentHeaderDelegate for the section label) followed by a SliverList of that section's items—so headers pin to the top as their section scrolls past, then get pushed off by the next section's header.
CustomScrollView(slivers: [
for (final section in sections) ...[
SliverPersistentHeader(pinned: true, delegate: SectionHeaderDelegate(section.title)),
SliverList(delegate: SliverChildListDelegate(section.items.map((i) => ListTile(title: Text(i))).toList())),
],
]);
Build a shimmer/skeleton loading placeholder while content loads.
The shimmer package wraps a gray placeholder layout (boxes matching the shape of real content) in a Shimmer.fromColors widget that animates a moving gradient over it. Show the shimmer skeleton while FutureBuilder's connectionState is waiting, then swap to real content once data arrives.
Build a reusable error/empty-state widget pattern for list screens.
A small stateless StateView widget takes an icon, message, and optional retry callback/button—reused across every screen for 'no results', 'no internet', and 'something went wrong', so the same three states aren't reimplemented ad hoc on each screen.
class StateView extends StatelessWidget {
final IconData icon;
final String message;
final VoidCallback? onRetry;
const StateView({super.key, required this.icon, required this.message, this.onRetry});
@override
Widget build(BuildContext context) => Center(child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48),
Text(message),
if (onRetry != null) ElevatedButton(onPressed: onRetry, child: const Text('Retry')),
],
));
}
Implement a retry mechanism with exponential backoff for a failing API call.
Wrap the API call in a loop up to a max attempt count; on failure, wait an increasing delay (e.g. doubling each time, optionally with jitter) before retrying, and rethrow once the max attempts are exhausted. This avoids hammering a struggling server while still recovering from transient failures.
Future<T> retry<T>(Future<T> Function() action, {int maxAttempts = 3}) async {
var attempt = 0;
while (true) {
try {
return await action();
} catch (e) {
attempt++;
if (attempt >= maxAttempts) rethrow;
await Future.delayed(Duration(seconds: 1 << attempt));
}
}
}
Design an offline-first data layer that syncs local changes when connectivity returns.
A repository always reads/writes to local storage (Hive/sqflite) first for instant UI response, queuing an 'unsynced' flag on each changed record. A background sync routine—triggered on connectivity restore or app resume—pushes queued changes to the API, then marks them synced or handles conflicts (e.g. last-write-wins or a merge strategy).
Build a real-time chat feature using WebSockets.
Connect with WebSocketChannel.connect(uri); its .stream is a broadcast Stream of incoming messages fed directly into a StreamBuilder, and channel.sink.add(message) sends outgoing messages. Always close the channel (channel.sink.close()) in dispose to avoid a dangling connection.
final channel = WebSocketChannel.connect(Uri.parse('wss://example.com/chat'));
channel.sink.add(jsonEncode({'text': 'hello'}));
StreamBuilder(
stream: channel.stream,
builder: (context, snapshot) => Text(snapshot.data?.toString() ?? ''),
);
Add a search icon to an AppBar that expands into a search field.
Toggle a boolean isSearching state; when true, replace the AppBar's title with a TextField and swap the search icon for a close icon that resets the state and clears the query. For a full search experience, Flutter's built-in showSearch(context: context, delegate: MySearchDelegate()) handles this pattern with its own dedicated screen.
Add unread-count badges to a bottom navigation bar's icons.
Wrap each tab icon in a Badge widget (Material 3's built-in Badge, or a Stack + Positioned circle for older versions), conditionally showing it only when that tab's unread count is greater than zero, sourced from a shared state/provider tracking notification counts per tab.
BottomNavigationBarItem(
icon: Badge(
label: Text('$unreadCount'),
isLabelVisible: unreadCount > 0,
child: const Icon(Icons.chat),
),
label: 'Chats',
);
Build a side drawer navigation menu.
Pass a Drawer widget to Scaffold(drawer: ...), containing a ListView of menu items (often with a DrawerHeader showing user info at top); each item's onTap should first call Navigator.pop(context) to close the drawer, then navigate to the target screen.
Build a settings screen with toggle switches and a slider.
Each setting (notifications on/off, font size) is a controlled widget—Switch(value: ..., onChanged: ...) or Slider(value: ..., onChanged: ...)—backed by a settings model/provider that persists changes immediately to shared_preferences as the user interacts, rather than requiring a separate 'Save' button.
SwitchListTile(
title: const Text('Enable notifications'),
value: settings.notificationsEnabled,
onChanged: (v) => settings.setNotifications(v),
);
Build a custom-styled slider from scratch using GestureDetector.
Use a GestureDetector's onHorizontalDragUpdate to read the drag's local position, clamp it to the track's width, and convert it to a 0-1 value which drives the thumb's position and the value reported to the parent—giving full control over the visual design that the built-in Slider doesn't allow.
GestureDetector(
onHorizontalDragUpdate: (details) {
final box = context.findRenderObject() as RenderBox;
final localX = box.globalToLocal(details.globalPosition).dx;
final value = (localX / box.size.width).clamp(0.0, 1.0);
onChanged(value);
},
);
Build a simple color picker grid.
Render a Wrap or GridView of predefined color swatches, each a tappable circle that sets the selected color in state and shows a check/border on the currently selected one; expose the selection via an onColorSelected callback so the parent controls what happens with it.
Wrap(children: colors.map((c) => GestureDetector(
onTap: () => onSelected(c),
child: CircleAvatar(backgroundColor: c, child: c == selected ? const Icon(Icons.check) : null),
)).toList());
Build a multi-select dropdown for choosing multiple tags.
Show current selections as chips, and tapping opens a showModalBottomSheet or dialog with a checkbox list bound to a Set<String> of selected values; toggling a checkbox adds/removes from the set, and closing the sheet returns the updated set to the parent.
Set<String> selected = {};
void toggle(String tag) {
setState(() => selected.contains(tag) ? selected.remove(tag) : selected.add(tag));
}
Build an autocomplete text field that suggests matches as the user types.
Flutter's built-in Autocomplete<T> widget handles the overlay suggestion list, keyboard navigation, and selection callback for you—just provide optionsBuilder to filter your data source based on the current text value.
Autocomplete<String>(
optionsBuilder: (textValue) => allCities.where(
(city) => city.toLowerCase().contains(textValue.text.toLowerCase()),
),
onSelected: (city) => print('Selected: $city'),
);
Format a phone number field as the user types (e.g. (123) 456-7890).
Attach a custom TextInputFormatter to the TextField's inputFormatters that strips non-digit characters from the raw input, then re-inserts formatting characters (parentheses, dash) at fixed digit positions, returning a new TextEditingValue with the cursor repositioned correctly.
Write a reusable validator that chains multiple rules (required, min length, pattern).
Compose small validator functions (String? Function(String?)) into one that runs each in order and returns the first non-null error message, short-circuiting on the first failure—so a single field can combine 'required' + 'min length' + 'format' rules declaratively.
String? Function(String?) combine(List<String? Function(String?)> validators) {
return (value) {
for (final v in validators) {
final error = v(value);
if (error != null) return error;
}
return null;
};
}
Build a form where the user can dynamically add or remove fields (e.g. multiple phone numbers).
Keep a List of controllers (one per dynamic field) in state. 'Add' appends a new TextEditingController and rebuilds; 'remove' disposes that specific controller and removes it from the list. Use the list index (or a generated id, not the controller itself) as each row's Key.
List<TextEditingController> controllers = [TextEditingController()];
void addField() => setState(() => controllers.add(TextEditingController()));
void removeField(int i) {
controllers[i].dispose();
setState(() => controllers.removeAt(i));
}
Download a file with Dio and show download progress.
dio.download(url, savePath, onReceiveProgress: (received, total)) streams the file directly to disk while reporting progress, avoiding loading the whole file into memory first—important for large files.
await dio.download(url, savePath, onReceiveProgress: (received, total) {
if (total != -1) setState(() => progress = received / total);
});
Build a custom disk cache manager for downloaded images.
On first request, check if a hashed version of the URL exists as a file in the app's cache directory (path_provider's getTemporaryDirectory()); if not, download it and write it to disk before returning the bytes, so subsequent requests read from disk instead of the network.
Implement infinite-scroll pagination in a GridView.
Same pattern as an infinite-scroll ListView: attach a ScrollController, detect nearing maxScrollExtent, and append the next page's items to the data list—the only difference is the grid uses a SliverGridDelegate instead of a linear layout, so the same scroll-position logic applies unchanged.
Build a list where items animate in and out when added/removed (AnimatedList).
AnimatedList requires a GlobalKey<AnimatedListState>; instead of calling setState directly, call listKey.currentState!.insertItem(index) or removeItem(index, builder), which drives an animation for exactly that item while keeping the underlying data list in sync manually.
final listKey = GlobalKey<AnimatedListState>();
void addItem(Item item) {
items.add(item);
listKey.currentState!.insertItem(items.length - 1);
}
Implement a Hero animation from a grid thumbnail to a full-screen detail view.
Wrap the thumbnail image and the detail screen's full image in matching Hero(tag: item.id, ...) widgets; when navigating between the two routes via Navigator.push, Flutter automatically animates the shared element between its two positions/sizes.
// Grid tile
Hero(tag: item.id, child: Image.network(item.thumbUrl));
// Detail screen
Hero(tag: item.id, child: Image.network(item.fullUrl));
Implement a custom page transition animation (e.g. slide-up) using PageRouteBuilder.
PageRouteBuilder lets you override transitionsBuilder, wrapping the destination page in an animated widget (like SlideTransition) driven by the provided animation controller—giving full control over enter/exit transitions beyond MaterialPageRoute's default.
Navigator.push(context, PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => const DetailPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) => SlideTransition(
position: Tween(begin: const Offset(0, 1), end: Offset.zero).animate(animation),
child: child,
),
));
Implement per-tab navigation stacks in a bottom-navigation app (each tab keeps its own back stack).
Give each tab its own nested Navigator (with a unique GlobalKey<NavigatorState>) inside an IndexedStack, so pushing a detail screen within Tab A doesn't affect Tab B's stack, and switching tabs preserves each one's navigation history independently.
Handle an incoming deep link and navigate to the corresponding screen.
Parse the incoming URI's path/query parameters into a route name and arguments, then use the navigator (ideally via go_router's declarative route matching, or a manual Navigator.pushNamed) to open the matching screen—handling the case where the app is cold-started from the link versus already running.
Implement an async counter/data-fetcher using Riverpod's AsyncNotifier.
AsyncNotifier<T> exposes state as an AsyncValue<T> (data/loading/error) automatically, and its build() method is where you fetch the initial data. Methods can update state optimistically or re-fetch, and the UI just calls .when(data:, loading:, error:) on the watched state.
class CounterNotifier extends AsyncNotifier<int> {
@override
Future<int> build() async => 0;
Future<void> increment() async {
final current = state.value ?? 0;
state = AsyncValue.data(current + 1);
}
}
Implement a repository that returns cached data immediately and falls back to it if the network call fails.
The repository first tries the remote data source; on success, it writes the fresh result to the local cache and returns it. On failure (timeout, no connection), it catches the error and returns whatever is in the local cache instead of propagating the failure, only throwing if the cache is also empty.
Future<List<Post>> getPosts() async {
try {
final posts = await remote.fetchPosts();
await local.savePosts(posts);
return posts;
} catch (_) {
final cached = await local.getPosts();
if (cached.isNotEmpty) return cached;
rethrow;
}
}
Combine debouncing with request cancellation for a search-as-you-type feature.
Debounce keystrokes with a Timer as usual, but also cancel the previous in-flight request using a Dio CancelToken before firing a new one—this prevents a slow earlier response from arriving after a newer one and overwriting the UI with stale results (a race condition debounce alone doesn't fully solve).
CancelToken? _cancelToken;
Future<void> search(String query) async {
_cancelToken?.cancel();
_cancelToken = CancelToken();
try {
final results = await dio.get('/search?q=$query', cancelToken: _cancelToken);
} catch (e) {
if (e is! DioException || e.type != DioExceptionType.cancel) rethrow;
}
}
Implement a throttle function that limits how often an action can run.
Unlike debounce (which waits for a pause), throttle guarantees the action runs at most once per fixed interval, executing immediately on the first call and then ignoring subsequent calls until the interval elapses—useful for things like limiting scroll-triggered API calls.
void Function() throttle(void Function() action, Duration interval) {
DateTime? lastRun;
return () {
final now = DateTime.now();
if (lastRun == null || now.difference(lastRun!) >= interval) {
lastRun = now;
action();
}
};
}
Implement an LRU (Least Recently Used) cache in Dart.
A LinkedHashMap preserves insertion order and lets you re-insert a key to move it to the end (marking it 'most recently used'). On get, remove and re-add the key to refresh its position; on put, evict the oldest entry (the map's first key) once capacity is exceeded. Both operations run in O(1).
class LRUCache<K, V> {
final int capacity;
final _map = <K, V>{};
LRUCache(this.capacity);
V? get(K key) {
if (!_map.containsKey(key)) return null;
final value = _map.remove(key) as V;
_map[key] = value;
return value;
}
void put(K key, V value) {
_map.remove(key);
_map[key] = value;
if (_map.length > capacity) _map.remove(_map.keys.first);
}
}
Implement binary search on a sorted list in Dart.
Binary search repeatedly halves the search range by comparing the middle element to the target, running in O(log n) time versus O(n) for a linear scan—but it requires the input list to already be sorted.
int binarySearch(List<int> sorted, int target) {
var low = 0, high = sorted.length - 1;
while (low <= high) {
final mid = low + (high - low) ~/ 2;
if (sorted[mid] == target) return mid;
if (sorted[mid] < target) { low = mid + 1; } else { high = mid - 1; }
}
return -1;
}
Implement merge sort in Dart.
Merge sort recursively splits the list in half until sublists have one element, then merges sorted sublists back together in order. It runs in guaranteed O(n log n) time (unlike quicksort's worst-case O(n²)) but uses O(n) extra space for the merge step.
List<int> mergeSort(List<int> list) {
if (list.length <= 1) return list;
final mid = list.length ~/ 2;
final left = mergeSort(list.sublist(0, mid));
final right = mergeSort(list.sublist(mid));
final result = <int>[];
var i = 0, j = 0;
while (i < left.length && j < right.length) {
result.add(left[i] <= right[j] ? left[i++] : right[j++]);
}
result.addAll(left.skip(i));
result.addAll(right.skip(j));
return result;
}
Implement quicksort in Dart.
Quicksort picks a pivot, partitions the list into elements less than and greater than the pivot, then recursively sorts each partition. Average time is O(n log n), but a poor pivot choice on already-sorted input can degrade to O(n²).
List<int> quickSort(List<int> list) {
if (list.length <= 1) return list;
final pivot = list[list.length ~/ 2];
final less = list.where((x) => x < pivot).toList();
final equal = list.where((x) => x == pivot).toList();
final greater = list.where((x) => x > pivot).toList();
return [...quickSort(less), ...equal, ...quickSort(greater)];
}
Implement a singly linked list in Dart.
A linked list is a chain of nodes, each holding a value and a reference to the next node, with a head pointer to the first one. Insertion at the head is O(1); appending at the tail or searching by value is O(n) without a tracked tail pointer.
class Node<T> {
T value;
Node<T>? next;
Node(this.value);
}
class LinkedList<T> {
Node<T>? head;
void addFirst(T value) {
final node = Node(value)..next = head;
head = node;
}
}
Write a function to reverse a singly linked list in Dart.
Walk the list once, keeping track of the previous node; for each node, redirect its next pointer to the previous node before moving forward—reversing all links in a single O(n) pass with O(1) extra space.
Node<T>? reverse<T>(Node<T>? head) {
Node<T>? prev;
var current = head;
while (current != null) {
final next = current.next;
current.next = prev;
prev = current;
current = next;
}
return prev;
}
Implement a Stack (LIFO) data structure in Dart.
A stack can be implemented directly on top of a Dart List, using add() for push and removeLast() for pop—both O(1) operations since they only touch the end of the underlying array.
class Stack<T> {
final _items = <T>[];
void push(T item) => _items.add(item);
T pop() => _items.removeLast();
T get peek => _items.last;
bool get isEmpty => _items.isEmpty;
}
Implement a Queue (FIFO) using two Stacks.
Push new elements onto an 'in' stack. To dequeue, if the 'out' stack is empty, pop everything from 'in' and push it onto 'out' (reversing the order), then pop from 'out'. Each element moves between stacks at most once, giving amortized O(1) per operation overall.
class QueueFromStacks<T> {
final _inStack = <T>[];
final _outStack = <T>[];
void enqueue(T item) => _inStack.add(item);
T dequeue() {
if (_outStack.isEmpty) {
while (_inStack.isNotEmpty) _outStack.add(_inStack.removeLast());
}
return _outStack.removeLast();
}
}
Implement insertion and search for a Binary Search Tree in Dart.
Each node holds a value and left/right child references. Insertion recursively compares the new value to the current node, going left if smaller and right if larger, until it finds an empty spot. Search follows the same comparison logic. Both run in O(log n) on a balanced tree, but degrade to O(n) on a skewed one.
class BSTNode {
int value;
BSTNode? left, right;
BSTNode(this.value);
void insert(int newValue) {
if (newValue < value) {
left == null ? left = BSTNode(newValue) : left!.insert(newValue);
} else {
right == null ? right = BSTNode(newValue) : right!.insert(newValue);
}
}
}
Write a function to detect if a list contains any duplicate elements.
Insert each element into a Set while iterating; if an element is already in the set, a duplicate exists. This runs in O(n) time versus O(n²) for comparing every pair.
bool hasDuplicates(List<int> list) {
final seen = <int>{};
for (final item in list) {
if (!seen.add(item)) return true; // add() returns false if already present
}
return false;
}
Implement Kadane's algorithm to find the maximum sum of a contiguous subarray.
Track a running sum that resets to the current element whenever including previous elements would make it smaller than starting fresh, and track the best sum seen so far. This solves the problem in a single O(n) pass, versus O(n²) for checking every subarray.
int maxSubArray(List<int> nums) {
var currentSum = nums[0];
var maxSum = nums[0];
for (var i = 1; i < nums.length; i++) {
currentSum = [nums[i], currentSum + nums[i]].reduce((a, b) => a > b ? a : b);
maxSum = [maxSum, currentSum].reduce((a, b) => a > b ? a : b);
}
return maxSum;
}
Write a function to check if a string has balanced parentheses/brackets.
Push every opening bracket onto a stack; on a closing bracket, check that it matches the type on top of the stack (pop it if so, otherwise the string is unbalanced). At the end, the stack must be empty for the string to be fully balanced.
bool isBalanced(String s) {
final stack = <String>[];
final pairs = {')': '(', ']': '[', '}': '{'};
for (final char in s.split('')) {
if ('([{'.contains(char)) {
stack.add(char);
} else if (pairs.containsKey(char)) {
if (stack.isEmpty || stack.removeLast() != pairs[char]) return false;
}
}
return stack.isEmpty;
}
Write a function to check if two strings are anagrams of each other.
Two strings are anagrams if they contain exactly the same characters with the same frequencies. Sort both strings' characters and compare (O(n log n)), or count character frequencies with a Map and compare counts (O(n), faster for longer strings).
bool isAnagram(String a, String b) {
final sortedA = a.toLowerCase().split('')..sort();
final sortedB = b.toLowerCase().split('')..sort();
return sortedA.join() == sortedB.join();
}
Write FizzBuzz in Dart (print Fizz/Buzz/FizzBuzz for multiples of 3/5/both from 1 to n).
Check divisibility by 15 (3 and 5) first, since checking 3 and 5 separately in the wrong order would print 'Fizz' or 'Buzz' instead of 'FizzBuzz' for multiples of both.
for (var i = 1; i <= n; i++) {
if (i % 15 == 0) {
print('FizzBuzz');
} else if (i % 3 == 0) {
print('Fizz');
} else if (i % 5 == 0) {
print('Buzz');
} else {
print(i);
}
}
Given a list containing n distinct numbers from 0 to n, find the missing one.
Compute the expected sum of 0..n using the formula n*(n+1)/2, then subtract the actual sum of the given list—the difference is the missing number. This runs in O(n) time and O(1) space, avoiding a sort or a hash set.
int findMissing(List<int> nums) {
final n = nums.length;
final expectedSum = n * (n + 1) ~/ 2;
final actualSum = nums.fold(0, (a, b) => a + b);
return expectedSum - actualSum;
}
Merge two already-sorted lists into one sorted list.
Use two pointers, one per list, repeatedly picking the smaller of the two current elements and advancing that pointer, then append whatever remains from the list that isn't exhausted yet. This is the same merge step used inside merge sort, running in O(n + m) time.
List<int> mergeSorted(List<int> a, List<int> b) {
final result = <int>[];
var i = 0, j = 0;
while (i < a.length && j < b.length) {
result.add(a[i] <= b[j] ? a[i++] : b[j++]);
}
result.addAll(a.skip(i));
result.addAll(b.skip(j));
return result;
}
Write a function to deep clone a nested Map/List structure of primitives.
Recursively check each value's runtime type: if it's a Map, clone each entry recursively into a new map; if it's a List, clone each element recursively into a new list; otherwise (a primitive), copy it directly since primitives are already immutable.
dynamic deepClone(dynamic value) {
if (value is Map) {
return value.map((k, v) => MapEntry(k, deepClone(v)));
} else if (value is List) {
return value.map(deepClone).toList();
}
return value;
}
Write a function that deeply compares two nested Lists/Maps for equality.
Dart's default == on List/Map is identity-based (or only shallow via ListEquality/MapEquality from package:collection), so a custom deep check must recursively compare each nested structure, handling type mismatches, differing lengths/key sets, and primitive comparisons as base cases.
bool deepEquals(dynamic a, dynamic b) {
if (a is Map && b is Map) {
if (a.length != b.length) return false;
return a.keys.every((k) => b.containsKey(k) && deepEquals(a[k], b[k]));
}
if (a is List && b is List) {
if (a.length != b.length) return false;
return List.generate(a.length, (i) => i).every((i) => deepEquals(a[i], b[i]));
}
return a == b;
}
Simulate a producer-consumer pattern using a Dart Stream.
A StreamController acts as the shared buffer: a 'producer' calls controller.sink.add(item) as items become available, and a 'consumer' calls controller.stream.listen((item) => process(item))—Dart's event loop naturally serializes delivery, so no manual locking is needed like in a multi-threaded language.
final controller = StreamController<int>();
controller.stream.listen((item) => print('Consumed: $item'));
for (var i = 0; i < 5; i++) {
controller.sink.add(i); // produce
}
controller.close();
Implement a simple type-safe event bus (pub/sub) using Streams.
A broadcast StreamController lets multiple independent listeners subscribe to the same event stream. Publishing calls add(event); subscribing filters by event type using stream.where((e) => e is T)—giving a decoupled way for distant parts of the app to communicate without direct references to each other.
class EventBus {
final _controller = StreamController<dynamic>.broadcast();
void fire(dynamic event) => _controller.add(event);
Stream<T> on<T>() => _controller.stream.where((e) => e is T).cast<T>();
}
Implement a reactive counter using GetX.
GetX's .obs extension turns a value into an observable (Rx) type; wrapping UI in Obx(() => ...) automatically rebuilds whenever any observable it reads changes—no BuildContext, Provider, or StreamBuilder boilerplate required.
class CounterController extends GetxController {
var count = 0.obs;
void increment() => count++;
}
// UI
Obx(() => Text('${controller.count}'));
Implement state restoration so a form survives the app being killed by the OS.
Mix in RestorationMixin, provide a unique restorationId, and register each piece of state that must survive process death (e.g. a RestorableTextEditingController) inside restoreState using registerForRestoration—the OS-provided restoration bucket persists these values so Android/iOS can restore the exact UI after the app is killed in the background and relaunched.
Write a function that counts the frequency of each word in a string.
Split the string on whitespace, normalize case, and use a Map<String, int> with update(word, (v) => v + 1, ifAbsent: () => 1) to tally occurrences in a single O(n) pass.
Map<String, int> wordFrequency(String text) {
final counts = <String, int>{};
for (final word in text.toLowerCase().split(RegExp(r'\s+'))) {
if (word.isEmpty) continue;
counts.update(word, (v) => v + 1, ifAbsent: () => 1);
}
return counts;
}
Write a function that splits a list into chunks of a given size.
Iterate in steps of size, slicing out a sublist at each step using sublist(start, end), clamping the end index to the list's length for the final (possibly shorter) chunk. Runs in O(n) overall.
List<List<T>> chunk<T>(List<T> list, int size) {
final chunks = <List<T>>[];
for (var i = 0; i < list.length; i += size) {
chunks.add(list.sublist(i, i + size > list.length ? list.length : i + size));
}
return chunks;
}
Write a function to find the first non-repeating character in a string.
Make one pass to build a character-frequency Map, then a second pass over the original string (to preserve order) returning the first character whose count is exactly 1. This runs in O(n) time with two linear passes rather than an O(n²) nested-loop comparison.
String? firstNonRepeating(String s) {
final counts = <String, int>{};
for (final c in s.split('')) {
counts.update(c, (v) => v + 1, ifAbsent: () => 1);
}
for (final c in s.split('')) {
if (counts[c] == 1) return c;
}
return null;
}