Building a Fully Responsive App
Capstone: Responsive Dashboard App
In this capstone lesson, we bring together every layout concept from this tutorial to build a fully responsive dashboard application. This dashboard adapts seamlessly across phones, tablets, and desktops with adaptive navigation, responsive grids, a collapsing app bar, a master-detail pattern, and proper safe area handling.
Step 1: Responsive Utilities
First, let’s create the foundational utilities that the entire app will use — breakpoints, device type detection, and a responsive builder widget.
responsive_utils.dart
import 'package:flutter/material.dart';
enum DeviceType { phone, tablet, desktop }
class Breakpoints {
static const double phone = 0;
static const double tablet = 600;
static const double desktop = 1024;
static const double desktopLarge = 1440;
static DeviceType getDeviceType(double width) {
if (width >= desktop) return DeviceType.desktop;
if (width >= tablet) return DeviceType.tablet;
return DeviceType.phone;
}
static int getGridColumns(double width) {
if (width >= desktopLarge) return 4;
if (width >= desktop) return 3;
if (width >= tablet) return 2;
return 1;
}
static double getContentMaxWidth(double width) {
if (width >= desktopLarge) return 1200;
if (width >= desktop) return 960;
return double.infinity;
}
}
class ResponsiveBuilder extends StatelessWidget {
final Widget Function(BuildContext, DeviceType, double) builder;
const ResponsiveBuilder({super.key, required this.builder});
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final deviceType = Breakpoints.getDeviceType(width);
return builder(context, deviceType, width);
}
}
class ResponsiveConstrainedBox extends StatelessWidget {
final Widget child;
const ResponsiveConstrainedBox({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final maxWidth = Breakpoints.getContentMaxWidth(width);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: child,
),
);
}
}
Step 2: App Theme and Constants
Define a consistent theme and data models used throughout the dashboard.
dashboard_theme.dart
import 'package:flutter/material.dart';
class DashboardTheme {
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.indigo,
brightness: Brightness.light,
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200),
),
),
appBarTheme: const AppBarTheme(
centerTitle: false,
elevation: 0,
),
);
}
class DashboardItem {
final String title;
final String value;
final IconData icon;
final Color color;
final double change;
const DashboardItem({
required this.title,
required this.value,
required this.icon,
required this.color,
required this.change,
});
}
class ActivityItem {
final String title;
final String description;
final String time;
final IconData icon;
const ActivityItem({
required this.title,
required this.description,
required this.time,
required this.icon,
});
}
// Sample data
const List<DashboardItem> kDashboardStats = [
DashboardItem(
title: 'Total Users',
value: '24,563',
icon: Icons.people,
color: Colors.blue,
change: 12.5,
),
DashboardItem(
title: 'Revenue',
value: '\$48,290',
icon: Icons.attach_money,
color: Colors.green,
change: 8.3,
),
DashboardItem(
title: 'Orders',
value: '1,847',
icon: Icons.shopping_cart,
color: Colors.orange,
change: -2.1,
),
DashboardItem(
title: 'Conversion',
value: '3.24%',
icon: Icons.trending_up,
color: Colors.purple,
change: 5.7,
),
];
const List<ActivityItem> kRecentActivity = [
ActivityItem(
title: 'New user registered',
description: 'John Doe created an account',
time: '2 min ago',
icon: Icons.person_add,
),
ActivityItem(
title: 'Order completed',
description: 'Order #1234 was delivered',
time: '15 min ago',
icon: Icons.check_circle,
),
ActivityItem(
title: 'Payment received',
description: '\$250.00 from Jane Smith',
time: '1 hour ago',
icon: Icons.payment,
),
ActivityItem(
title: 'New review',
description: '5-star review on Product A',
time: '2 hours ago',
icon: Icons.star,
),
ActivityItem(
title: 'Inventory alert',
description: 'Product B is running low',
time: '3 hours ago',
icon: Icons.warning,
),
ActivityItem(
title: 'Campaign launched',
description: 'Summer Sale campaign is live',
time: '5 hours ago',
icon: Icons.campaign,
),
];
Step 3: Adaptive Navigation Shell
The navigation shell is the heart of our responsive app. It switches between a bottom navigation bar on phones, a compact navigation rail on tablets, and a full extended navigation rail (drawer-like) on desktops.
adaptive_shell.dart
import 'package:flutter/material.dart';
class AdaptiveShell extends StatefulWidget {
const AdaptiveShell({super.key});
@override
State<AdaptiveShell> createState() => _AdaptiveShellState();
}
class _AdaptiveShellState extends State<AdaptiveShell> {
int _selectedIndex = 0;
static const _navItems = [
(icon: Icons.dashboard, label: 'Dashboard'),
(icon: Icons.analytics, label: 'Analytics'),
(icon: Icons.people, label: 'Users'),
(icon: Icons.settings, label: 'Settings'),
];
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (context, deviceType, width) {
return Scaffold(
body: Row(
children: [
// Desktop: Extended navigation rail (like a drawer)
if (deviceType == DeviceType.desktop)
NavigationRail(
extended: width >= Breakpoints.desktopLarge,
minExtendedWidth: 220,
selectedIndex: _selectedIndex,
onDestinationSelected: _onItemSelected,
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: width >= Breakpoints.desktopLarge
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.dashboard_customize,
color: Colors.indigo),
SizedBox(width: 8),
Text(
'Dashboard',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
)
: const Icon(Icons.dashboard_customize,
color: Colors.indigo, size: 32),
),
destinations: _navItems
.map((item) => NavigationRailDestination(
icon: Icon(item.icon),
label: Text(item.label),
))
.toList(),
)
// Tablet: Compact navigation rail
else if (deviceType == DeviceType.tablet)
NavigationRail(
extended: false,
selectedIndex: _selectedIndex,
onDestinationSelected: _onItemSelected,
leading: const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Icon(Icons.dashboard_customize,
color: Colors.indigo, size: 28),
),
destinations: _navItems
.map((item) => NavigationRailDestination(
icon: Icon(item.icon),
label: Text(item.label),
))
.toList(),
),
// Page content
Expanded(
child: _buildPage(_selectedIndex),
),
],
),
// Phone: Bottom navigation bar
bottomNavigationBar: deviceType == DeviceType.phone
? NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: _onItemSelected,
destinations: _navItems
.map((item) => NavigationDestination(
icon: Icon(item.icon),
label: item.label,
))
.toList(),
)
: null,
);
},
);
}
void _onItemSelected(int index) {
setState(() => _selectedIndex = index);
}
Widget _buildPage(int index) {
return switch (index) {
0 => const DashboardPage(),
1 => const AnalyticsPage(),
2 => const UsersPage(),
3 => const SettingsPage(),
_ => const DashboardPage(),
};
}
}
Step 4: Dashboard Page with Collapsing App Bar
The main dashboard page features a SliverAppBar with search functionality that collapses as the user scrolls, followed by responsive stat cards and an activity feed.
dashboard_page.dart
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (context, deviceType, width) {
return CustomScrollView(
slivers: [
// Collapsing app bar with search
SliverAppBar(
expandedHeight: deviceType == DeviceType.phone
? 140
: 160,
floating: true,
pinned: true,
snap: true,
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.only(
left: 16,
bottom: 16,
right: 16,
),
title: deviceType == DeviceType.phone
? null
: const Text(
'Dashboard Overview',
style: TextStyle(fontSize: 16),
),
background: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (deviceType == DeviceType.phone)
const Text(
'Dashboard',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
// Search bar
TextField(
decoration: InputDecoration(
hintText: 'Search dashboard...',
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(vertical: 12),
),
),
],
),
),
),
),
),
// Stats grid
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: Breakpoints.getGridColumns(width),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: deviceType == DeviceType.phone
? 2.5
: 2.0,
),
delegate: SliverChildBuilderDelegate(
(context, index) =>
StatCard(item: kDashboardStats[index]),
childCount: kDashboardStats.length,
),
),
),
// Section title
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Recent Activity',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
// Activity feed
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.separated(
itemCount: kRecentActivity.length,
itemBuilder: (context, index) =>
ActivityCard(item: kRecentActivity[index]),
separatorBuilder: (_, __) => const SizedBox(height: 8),
),
),
// Bottom safe area spacing
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.paddingOf(context).bottom + 16,
),
),
],
);
},
);
}
}
Step 5: Stat Card and Activity Card Widgets
These reusable card widgets adapt their layout based on the available space.
stat_card.dart
class StatCard extends StatelessWidget {
final DashboardItem item;
const StatCard({super.key, required this.item});
@override
Widget build(BuildContext context) {
final isPositive = item.change >= 0;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(item.icon, color: item.color, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.title,
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
item.value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: (isPositive ? Colors.green : Colors.red)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isPositive
? Icons.arrow_upward
: Icons.arrow_downward,
size: 14,
color: isPositive ? Colors.green : Colors.red,
),
Text(
'\${item.change.abs()}%',
style: TextStyle(
color: isPositive ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
],
),
),
);
}
}
class ActivityCard extends StatelessWidget {
final ActivityItem item;
const ActivityCard({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Icon(
item.icon,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
title: Text(
item.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(item.description),
trailing: Text(
item.time,
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
),
);
}
}
Step 6: Users Page with Master-Detail Pattern
The Users page demonstrates the master-detail pattern. On phones, selecting a user navigates to a detail page. On tablets and desktops, the detail panel appears alongside the list.
users_page.dart
class UsersPage extends StatefulWidget {
const UsersPage({super.key});
@override
State<UsersPage> createState() => _UsersPageState();
}
class _UsersPageState extends State<UsersPage> {
int? _selectedUserIndex;
final List<Map<String, dynamic>> _users = List.generate(
15,
(i) => {
'name': 'User \${i + 1}',
'email': 'user\${i + 1}@example.com',
'role': i % 3 == 0 ? 'Admin' : (i % 3 == 1 ? 'Editor' : 'Viewer'),
'status': i % 4 != 0 ? 'Active' : 'Inactive',
'joined': '\${12 - (i % 12)} months ago',
'orders': (i + 1) * 7,
'revenue': (i + 1) * 142.50,
},
);
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (context, deviceType, width) {
final showDetail = deviceType != DeviceType.phone;
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Expanded(
child: Text(
'Users',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.add, size: 18),
label: Text(
deviceType == DeviceType.phone
? 'Add'
: 'Add User',
),
),
],
),
),
// Content
Expanded(
child: showDetail
? Row(
children: [
SizedBox(
width: deviceType == DeviceType.desktop
? 400
: 320,
child: _buildUserList(),
),
const VerticalDivider(width: 1),
Expanded(
child: _selectedUserIndex != null
? _buildUserDetail(
_users[_selectedUserIndex!])
: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_search,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Select a user to view details',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
),
),
],
)
: _buildUserList(),
),
],
),
);
},
);
}
Widget _buildUserList() {
return ListView.builder(
itemCount: _users.length,
itemBuilder: (context, index) {
final user = _users[index];
final isSelected = _selectedUserIndex == index;
return ListTile(
selected: isSelected,
selectedTileColor: Colors.indigo.withValues(alpha: 0.08),
leading: CircleAvatar(
backgroundColor: Colors.primaries[index % Colors.primaries.length],
child: Text(
user['name'][0],
style: const TextStyle(color: Colors.white),
),
),
title: Text(
user['name'],
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(user['email']),
trailing: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: user['status'] == 'Active'
? Colors.green.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
user['status'],
style: TextStyle(
fontSize: 12,
color: user['status'] == 'Active'
? Colors.green
: Colors.grey,
),
),
),
onTap: () {
setState(() => _selectedUserIndex = index);
final width = MediaQuery.sizeOf(context).width;
if (width < Breakpoints.tablet) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => Scaffold(
appBar: AppBar(title: Text(user['name'])),
body: _buildUserDetail(user),
),
),
);
}
},
);
},
);
}
Widget _buildUserDetail(Map<String, dynamic> user) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// User header
Center(
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: Colors.indigo,
child: Text(
user['name'][0],
style: const TextStyle(
color: Colors.white,
fontSize: 32,
),
),
),
const SizedBox(height: 12),
Text(
user['name'],
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
user['email'],
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
const SizedBox(height: 24),
// Info cards
_buildInfoRow('Role', user['role']),
_buildInfoRow('Status', user['status']),
_buildInfoRow('Joined', user['joined']),
_buildInfoRow('Total Orders', '\${user['orders']}'),
_buildInfoRow(
'Total Revenue',
'\$\${user['revenue'].toStringAsFixed(2)}',
),
const SizedBox(height: 24),
// Action buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.edit),
label: const Text('Edit'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.email),
label: const Text('Contact'),
),
),
],
),
],
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
],
),
);
}
}
Step 7: Analytics and Settings Pages
Placeholder pages that also demonstrate responsive layout principles.
analytics_page.dart and settings_page.dart
class AnalyticsPage extends StatelessWidget {
const AnalyticsPage({super.key});
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (context, deviceType, width) {
final columns = Breakpoints.getGridColumns(width);
return SafeArea(
child: CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Analytics',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid(
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.bar_chart,
color: Colors.primaries[
index %
Colors.primaries.length],
),
const SizedBox(width: 8),
Text(
'Metric \${index + 1}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
const Spacer(),
// Chart placeholder
Expanded(
flex: 2,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius:
BorderRadius.circular(8),
),
child: const Center(
child: Icon(
Icons.show_chart,
size: 32,
color: Colors.grey,
),
),
),
),
],
),
),
);
},
childCount: 8,
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: MediaQuery.paddingOf(context).bottom + 16,
),
),
],
),
);
},
);
}
}
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: ResponsiveConstrainedBox(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Settings',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildSection('Account', [
_buildSettingsTile(
Icons.person,
'Profile',
'Update your personal information',
),
_buildSettingsTile(
Icons.lock,
'Security',
'Password, 2FA, and sessions',
),
_buildSettingsTile(
Icons.notifications,
'Notifications',
'Configure alert preferences',
),
]),
const SizedBox(height: 16),
_buildSection('Preferences', [
_buildSettingsTile(
Icons.palette,
'Appearance',
'Theme, language, and display',
),
_buildSettingsTile(
Icons.storage,
'Data & Storage',
'Manage cached data',
),
]),
],
),
),
);
}
Widget _buildSection(String title, List<Widget> children) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
...children,
],
),
);
}
Widget _buildSettingsTile(
IconData icon,
String title,
String subtitle,
) {
return ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
);
}
}
Step 8: Putting It All Together
Finally, the main entry point wires up the theme and the adaptive shell.
main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Edge-to-edge mode for modern feel
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
),
);
runApp(const DashboardApp());
}
class DashboardApp extends StatelessWidget {
const DashboardApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Responsive Dashboard',
theme: DashboardTheme.lightTheme,
debugShowCheckedModeBanner: false,
home: const AdaptiveShell(),
);
}
}
setState for sharing the selected user index across the master-detail views. The examples here use setState for clarity, but larger apps benefit from centralized state management.
How the Concepts Connect
Let’s review how every layout concept from this tutorial appears in our dashboard app:
- Breakpoint System (Lessons 1-2) —
Breakpointsclass drives all responsive decisions: navigation type, grid columns, content width. - Flex, Row, Column (Lesson 3) — The navigation rail + content area use
Row. User detail layout usesColumn. - MediaQuery (Lesson 5) —
MediaQuery.sizeOf(context)provides screen dimensions for breakpoint calculations. - LayoutBuilder (Lesson 6) —
ResponsiveBuilderencapsulates the pattern of reading size and computing device type. - Slivers (Lesson 13) —
SliverAppBarwith search,SliverGridfor stats,SliverList.separatedfor activity. - OrientationBuilder (Lesson 14) — Grid columns adapt to orientation within the breakpoint system.
- SafeArea (Lesson 15) — Applied to every page for proper notch and home indicator handling.
- SystemChrome (Lesson 15) — Edge-to-edge mode with transparent system bars in
main().
1. Always start with a breakpoint system and use it consistently.
2. Use adaptive navigation that matches the platform convention.
3. Implement master-detail for data-heavy pages on larger screens.
4. Use
SliverAppBar for rich scrolling headers.
5. Wrap page content in
SafeArea when there is no AppBar.
6. Set edge-to-edge mode for a modern look.
7. Constrain content width on very large screens.
8. Test on phone, tablet, and desktop form factors.