Build a Healthcare App in Flutter
Build a green Flutter healthcare app with onboarding, booking, and records.
Introduction
30 Second Summary
Finding a doctor often means jumping between provider pages before you can compare a single appointment. A confusing booking flow makes a simple task feel harder than it should.
In this project, you will build CarePath, a mobile-width healthcare appointment experience using Flutter. Its complete journey takes a user from doctor discovery to a confirmed appointment on the home screen.
What You'll Build
Open CarePath at mobile width to demo a complete journey from finding Dr. Maya Chen to seeing her confirmed appointment on the dashboard.
By the end of this project, you'll have:
- Use a polished Material 3 dashboard with clear healthcare actions.
- Switch from Home to Reports with the bottom navigation. Reach Profile through the same control.
- Filter a fictional doctor directory by specialty. Open a profile to review available slots. Book a date and time. See the confirmation back on the home screen.
- Secret Mission: add live doctor search that narrows the doctor list as the user types.
Are there any prerequisites?
This project assumes general programming experience. Flutter and Visual Studio Code should already be installed on macOS.
Before We Start
This is your moment to commit to CarePath as a focused fictional healthcare appointment prototype. You will build the doctor discovery and booking journey with Flutter's local web-server target before taking on native iOS tooling.
Set Up and Run the Minimal Project
A tiny runnable Flutter baseline gives CarePath a clean starting point. This keeps tooling problems separate from the UI work ahead.
You'll verify Flutter 3.47.8 as the project's SDK. A local web server lets Safari show the first screen while Xcode stays outside this project's setup.
In this step, get ready to:
- Confirm the installed Flutter SDK version and tooling status.
- Generate the healthcare_app web scaffold.
- Run the minimal app in Safari.
Verify the Flutter SDK
The SDK version controls which project templates and APIs Flutter provides. Verifying it now gives every later step the same foundation.
The upgrade can take a few minutes while Flutter downloads SDK files. A quiet Terminal window during that download is expected.
- Press Cmd+Space to open Spotlight.
- Type Terminal into the search field.
- Press Return to open Terminal.
- Update the installed Flutter SDK by running this command:
flutter upgrade
What does this command do?
This updates Flutter to the most recent version available on its current channel. A consistent SDK keeps the generated project files predictable.
Upgrade not completing?
- Check that your Mac has an active internet connection.
- Keep Terminal open until the download finishes.
Ask for help with the SDK update: Why is my Flutter upgrade not completing?
- Check the installed Flutter version by running this command:
flutter --version
What does this command do?
This prints the Flutter SDK version installed on your Mac. The project uses Flutter 3.47.8.
✔️ I see Flutter 3.47.8
Your SDK matches the version used throughout this project.
ⓧ I see an older version
Your installed SDK needs an update before you create the project.
- Open the official Flutter installation guide.
- Follow the macOS instructions to replace the older SDK.
- Return to the Terminal window from earlier.
- Repeat the upgrade command shown above.
- Repeat the version check shown above.
ⓧ Command not found
Terminal cannot currently locate the Flutter SDK.
- Open the official Flutter installation guide.
- Follow the macOS installation instructions.
- Close Terminal after the installation finishes.
- Open a fresh Terminal window through Spotlight.
- Repeat the upgrade command shown above.
- Repeat the version check shown above.
- Check Flutter's installed tooling by running this command:
flutter doctor
What does this command do?
Flutter Doctor checks the tooling available on your Mac. Focus on the Flutter entry because native iOS tooling belongs to a follow-on project.
You should see a checkmark beside Flutter. Other platform checks can flag missing iOS tooling because Xcode is outside this project's scope.
No checkmark beside Flutter?
- Read the guidance printed beneath the Flutter entry.
- Complete the Flutter-specific fix listed by the doctor check.
- Repeat the doctor check shown above.
Get help with the result: Why does Flutter Doctor show an issue for Flutter?
Create and inspect the minimal project
A generated scaffold supplies the folders and configuration Flutter needs to run. The empty template keeps the first screen small enough to verify quickly.
- Move to your Desktop by running this command:
cd ~/Desktop
Why start on the Desktop?
The Desktop gives the new project a predictable location. You can find the healthcare_app folder easily from Terminal or Visual Studio Code.
- Create the minimal Flutter project by running this command:
flutter create healthcare_app --empty
What does this command create?
The empty template creates a minimal Flutter application inside healthcare_app. It also generates the manifest and browser runner required for this project.
Project not created?
- Confirm Terminal is still on your Desktop.
- Rename any existing healthcare_app folder if Flutter reports a destination conflict.
- Repeat the create command shown above.
Ask for help with the scaffold: Why did Flutter fail to create my healthcare_app project?
- Enter the generated project directory by running this command:
cd healthcare_app
Why enter the project directory?
Flutter commands use the current directory to find the project manifest. Terminal now targets the generated healthcare_app project.
Your first project milestone is in place: the complete Flutter scaffold now exists on your Desktop.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into the search field.
- Press Return to open Visual Studio Code.
- Click File in the menu bar.
- Click Open Folder....
- Select the healthcare_app folder on your Desktop.
- Click Open.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
What did Flutter generate?
The lib/main.dart file is the minimal application entry point. It calls runApp with a MaterialApp.
The pubspec.yaml file records the Flutter dependencies. The web/ folder contains the browser runner scaffold.
- Expand the lib folder in the Explorer sidebar.
- Select main.dart.
- Confirm the file contains MaterialApp and Hello World!.
- Select pubspec.yaml in the Explorer sidebar.
- Confirm the manifest lists Flutter SDK dependencies and the Flutter test dependency.
- Expand the web/ folder in the Explorer sidebar.
- Confirm the generated browser runner files are listed inside web/.
✔️ Awesome, I've got everything!
The generated entry point, project manifest, and web scaffold are all present.
ⓧ I'd like to double check the full code
The generated lib/main.dart should match this file.
import 'package:flutter/material.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: Center(child: Text('Hello World!'))),
);
}
}
How does the minimal app work?
- The Flutter material import provides the widgets used by the app.
- The main() function starts the widget tree with runApp.
- The MainApp widget returns a Material application with centered greeting text.
Run the app in Safari
The local web-server target serves the existing Flutter app at a URL on your Mac. Safari loads that URL while Terminal keeps the development process running.
Before you start the server, which text do you expect Safari to show?
- Start Flutter's local development server by running this command:
flutter run -d web-server
What does this command do?
This starts Flutter's local development server for the web target. Terminal prints the local URL that points to the running app.
The server keeps this Terminal session occupied so it can continue serving the project.
- Record the local URL printed in Terminal here: your printed local URL.
- Press Cmd+Space to open Spotlight.
- Type Safari into the search field.
- Press Return to open Safari.
- Paste your printed local URL into Safari's address field.
- Press Return to load the app.
You should see Hello World! centered in the browser. Your Flutter project is now running from the local web server.
- Keep the Terminal process running for the next step.
App not loading in Safari?
- Confirm the Flutter process is still running in Terminal.
- Copy the complete local URL printed by Flutter.
- Paste the URL into Safari again.
Ask for help with the preview: Why is my Flutter web-server app not loading in Safari?
Your CarePath baseline is live in Safari. Next, you'll replace this minimal screen with the professional healthcare shell.
Build the Professional Healthcare Shell
Your running Flutter project proves the browser workflow is ready. However, a single greeting cannot show how the finished healthcare journey fits together.
This step turns that baseline into the CarePath dashboard. A shared Material 3 theme keeps the visual system consistent. A stateful app shell keeps Home, Reports, and Profile connected.
In this step, get ready to:
- Create the shared CarePath theme.
- Build the stateful app shell with three primary destinations.
- Add the healthcare dashboard and test its primary action.
Create the shared theme and shell state
A central theme gives every screen the same colors and button treatment. The app shell also needs one source of truth for the selected destination.
- Switch back to the healthcare_app project in VS Code.
- In the VS Code Explorer sidebar, create the core folder inside lib.
- Inside lib/core, create the theme folder.
- Inside lib/core/theme, create app_theme.dart.
- Add the CarePath color system to app_theme.dart by pasting this code:
import 'package:flutter/material.dart';
abstract final class AppTheme {
static const brand = Color(0xFF246BFD);
static const canvas = Color(0xFFF4F7FB);
static ThemeData get light {
final colorScheme = ColorScheme.fromSeed(
seedColor: brand,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: canvas,
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
);
}
}
What does this theme do?
- The brand color seeds a readable color scheme for CarePath.
- The canvas color gives every scaffold the same calm background.
- The filled-button theme gives primary actions a consistent height and rounded shape.
- Save lib/core/theme/app_theme.dart.
- Confirm that app_theme.dart appears inside lib/core/theme in the Explorer sidebar.
Theme file showing import errors?
Confirm that app_theme.dart sits inside the generated healthcare_app/lib/core/theme folders. Check that the first import matches the code above.
Ask for help with the file structure here: Why does app_theme.dart show Flutter import errors?
The next two files establish a temporary repository pattern boundary and the shell's selected-tab state. Doctor records arrive in the next step.
- In the VS Code Explorer sidebar, create the data folder inside lib.
- Inside lib/data, create health_repository.dart.
- Add the temporary repository placeholder by pasting this code:
class LocalHealthRepository {
const LocalHealthRepository();
}
Why add a placeholder repository?
The shell receives its data dependency from one dedicated object. The next step can replace this empty boundary with fictional doctor records without redesigning the app shell.
- Save lib/data/health_repository.dart.
- In the VS Code Explorer sidebar, create the presentation folder inside lib.
- Inside lib/presentation, create healthcare_view_model.dart.
- Add the shell state to healthcare_view_model.dart by pasting this code:
import 'package:flutter/foundation.dart';
import '../data/health_repository.dart';
class HealthcareViewModel extends ChangeNotifier {
HealthcareViewModel(this._repository);
final LocalHealthRepository _repository;
int _selectedTab = 0;
int get selectedTab => _selectedTab;
Object? get upcomingAppointment => null;
void selectTab(int index) {
if (_selectedTab == index) return;
_selectedTab = index;
notifyListeners();
}
}
How does the shell state work?
- ChangeNotifier lets the view model announce a state change to listening widgets.
- The selectedTab getter exposes the active destination without allowing a widget to overwrite the field directly.
- The selectTab() method updates the destination only when the index changes.
- The appointment getter establishes the empty dashboard state used in this shell.
- Save lib/presentation/healthcare_view_model.dart.
- Confirm that health_repository.dart appears inside lib/data in the Explorer sidebar.
- Confirm that healthcare_view_model.dart appears inside lib/presentation in the Explorer sidebar.
View model showing type errors?
Check that the repository file is named health_repository.dart. Confirm that both files sit inside the folders shown above.
Ask for help with the dependency here: Why can HealthcareViewModel not find LocalHealthRepository?
Build the dashboard and app shell
The dashboard composes small widgets into one mobile-width experience. The app shell places that experience beside two placeholders inside an IndexedStack so destination state stays available while the user switches tabs.
- Inside lib/presentation, create home_screen.dart.
- Replace the contents of home_screen.dart with the complete file in the double-check tab below.
✔️ I added HomeScreen
Save lib/presentation/home_screen.dart. The dashboard view is ready for the app shell.
ⓧ I'd like to double check the full code
Use this complete version of lib/presentation/home_screen.dart for comparison.
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({
required this.onFindDoctor,
required this.onOpenReports,
required this.onOpenProfile,
super.key,
});
final VoidCallback onFindDoctor;
final VoidCallback onOpenReports;
final VoidCallback onOpenProfile;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Good morning', style: textTheme.bodyLarge),
Text(
'Alex Parker',
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
const CircleAvatar(child: Text('AP')),
],
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF246BFD), Color(0xFF6A8DFF)],
),
borderRadius: BorderRadius.circular(24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Care that fits your day',
style: textTheme.headlineSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'Discover a clinician and reserve a convenient time.',
style: TextStyle(color: Colors.white),
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: onFindDoctor,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF164FCA),
),
icon: const Icon(Icons.search),
label: const Text('Find a doctor'),
),
],
),
),
const SizedBox(height: 24),
Text(
'Upcoming appointment',
style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
const _AppointmentCard(),
const SizedBox(height: 24),
Text(
'Quick access',
style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _QuickAccessCard(
icon: Icons.description_outlined,
title: 'Reports',
caption: 'Preview area',
onTap: onOpenReports,
),
),
const SizedBox(width: 12),
Expanded(
child: _QuickAccessCard(
icon: Icons.person_outline,
title: 'Health profile',
caption: 'Preview area',
onTap: onOpenProfile,
),
),
],
),
],
),
);
}
}
class _AppointmentCard extends StatelessWidget {
const _AppointmentCard();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.event_available_outlined),
SizedBox(width: 12),
Expanded(child: Text('No appointment booked yet.')),
],
),
);
}
}
class _QuickAccessCard extends StatelessWidget {
const _QuickAccessCard({
required this.icon,
required this.title,
required this.caption,
required this.onTap,
});
final IconData icon;
final String title;
final String caption;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(caption),
],
),
),
),
);
}
}
How is the dashboard composed?
- SafeArea keeps the dashboard clear of browser or operating-system intrusions.
- The hero card gives the doctor-discovery action the strongest visual position.
- The appointment card establishes a visible empty state before any booking exists.
- The quick-access cards reuse one widget for Reports and Health profile.
- Save lib/presentation/home_screen.dart.
- Confirm that VS Code shows no red error markers inside home_screen.dart.
- Inside lib, create app.dart.
- Replace the contents of app.dart with the complete file in the double-check tab below.
✔️ I added the app shell
Save lib/app.dart. CarePath now has a stateful shell for its three primary destinations.
ⓧ I'd like to double check the full code
Use this complete version of lib/app.dart for comparison.
import 'package:flutter/material.dart';
import 'core/theme/app_theme.dart';
import 'data/health_repository.dart';
import 'presentation/healthcare_view_model.dart';
import 'presentation/home_screen.dart';
class HealthcareApp extends StatefulWidget {
const HealthcareApp({super.key});
@override
State<HealthcareApp> createState() => _HealthcareAppState();
}
class _HealthcareAppState extends State<HealthcareApp> {
late final HealthcareViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = HealthcareViewModel(const LocalHealthRepository());
}
@override
void dispose() {
_viewModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'CarePath',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
home: ListenableBuilder(
listenable: _viewModel,
builder: (context, child) => _AppShell(viewModel: _viewModel),
),
);
}
}
class _AppShell extends StatelessWidget {
const _AppShell({required this.viewModel});
final HealthcareViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: IndexedStack(
index: viewModel.selectedTab,
children: [
HomeScreen(
onFindDoctor: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Doctor discovery is not connected yet',
),
),
);
},
onOpenReports: () => viewModel.selectTab(1),
onOpenProfile: () => viewModel.selectTab(2),
),
const _PlaceholderScreen(
icon: Icons.description_outlined,
title: 'Medical reports',
message: 'Report storage arrives in a follow-on project.',
),
const _PlaceholderScreen(
icon: Icons.person_outline,
title: 'Health profile',
message: 'Secure profile records arrive in a follow-on project.',
),
],
),
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: viewModel.selectedTab,
onDestinationSelected: viewModel.selectTab,
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.description_outlined),
selectedIcon: Icon(Icons.description),
label: 'Reports',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
class _PlaceholderScreen extends StatelessWidget {
const _PlaceholderScreen({
required this.icon,
required this.title,
required this.message,
});
final IconData icon;
final String title;
final String message;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 52),
const SizedBox(height: 16),
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text(message, textAlign: TextAlign.center),
],
),
),
),
);
}
}
What does the app shell manage?
- The HealthcareApp state creates one view model and disposes it with the app.
- ListenableBuilder rebuilds the shell whenever the view model announces a tab change.
- The NavigationBar sends the selected destination index to selectTab().
- ConstrainedBox keeps the web preview at a mobile-friendly maximum width.
- Save lib/app.dart.
- Return to the existing lib/main.dart tab.
- Replace the generated entry point with this code:
import 'package:flutter/material.dart';
import 'app.dart';
void main() {
runApp(const HealthcareApp());
}
What does the entry point do?
The entry point imports the app shell and gives HealthcareApp to runApp(). CarePath now starts from one dedicated application widget.
- Save lib/main.dart.
- Switch back to the Terminal session running flutter run -d web-server.
- Apply the new app entry point by entering uppercase R in that Terminal session.
Safari now shows the CarePath home dashboard. You should see Alex Parker, the blue hero card, an empty upcoming-appointment card, and two quick-access cards.
Browser still showing Hello World?
Confirm that lib/main.dart imports app.dart. Make sure you entered uppercase R in the Terminal session that is still serving the app.
Ask for help with the entry point here: Why does my Flutter web app still show Hello World after I replaced main.dart?
Test the navigation and disconnected action
The visible shell only counts as progress when every destination responds. You will also test the main call-to-action before extending its journey.
- In Safari, tap Reports in the bottom navigation.
You should see the Medical reports placeholder with its document icon.
- Tap Profile in the bottom navigation.
You should see the Health profile placeholder with its profile icon.
- Tap Home in the bottom navigation.
The Alex Parker dashboard returns with the same empty appointment state.
Before you test the primary action, what do you think the current shell can do when doctor discovery has no screen or data source?
- Tap Find a doctor on the blue hero card.
You will see a temporary SnackBar reading Doctor discovery is not connected yet. This shortfall is intentional. It proves that the call-to-action responds while exposing the missing discovery journey.
- Return to VS Code.
- Make a harmless whitespace change in lib/main.dart.
- Save lib/main.dart.
- Switch back to the running Terminal session.
- Trigger hot reload by entering lowercase r.
The running app stays on the current destination after hot reload. This confirms that your browser development loop is still active.
Navigation or SnackBar not responding?
Check that NavigationBar uses viewModel.selectTab for onDestinationSelected. Confirm that the IndexedStack reads viewModel.selectedTab.
Ask for help with the interaction here: Why does my CarePath navigation or Find a doctor SnackBar not respond?
That is the shell working as a product: CarePath has a shared visual system, responsive dashboard, and functioning primary navigation. Next, you will replace the temporary doctor action with repository-backed discovery and real detail screens.
Connect Doctor Discovery
Your CarePath shell now looks like a healthcare product. However, its main action still ends at a temporary message instead of helping someone find a doctor.
This step connects that action to a discovery journey. A repository keeps fictional doctor records outside the widgets. A view model then turns those records into filterable UI state.
In this step, get ready to:
- Define immutable doctor and appointment models.
- Load fictional doctors through a local repository.
- Connect specialty filters and doctor details to the dashboard.
Model doctors and repository data
An immutable model gives each doctor a predictable shape. Widgets can read these values without owning or changing the underlying records.
- Create a new folder named domain inside the lib folder using the VS Code Explorer sidebar.
- Create health_models.dart inside lib/domain using the New File button in the Explorer sidebar.
- Add the Doctor model to health_models.dart by pasting this code:
class Doctor {
const Doctor({
required this.id,
required this.name,
required this.specialty,
required this.hospital,
required this.rating,
required this.yearsExperience,
required this.bio,
required this.availableDates,
required this.timeSlots,
});
final String id;
final String name;
final String specialty;
final String hospital;
final double rating;
final int yearsExperience;
final String bio;
final List<String> availableDates;
final List<String> timeSlots;
String get initials => name
.split(' ')
.where((part) => part.isNotEmpty && part != 'Dr.')
.take(2)
.map((part) => part[0])
.join();
}
What does the Doctor model hold?
- The required constructor ensures every doctor has the information used by the list and detail screens.
- The final fields keep each doctor record unchanged after creation.
- The initials getter converts a name such as Dr. Maya Chen into MC for the avatar.
- Add the Appointment model directly below the Doctor class:
class Appointment {
const Appointment({
required this.doctor,
required this.date,
required this.time,
});
final Doctor doctor;
final String date;
final String time;
}
Why define Appointment now?
The repository and dashboard need a shared type for the future booking result. Defining it beside Doctor keeps related domain data together.
- Save health_models.dart.
- Confirm the running terminal completes a hot reload without reporting a Dart error.
Seeing an error in health_models.dart?
- Check that Appointment sits below the closing brace of Doctor.
- Check that each required constructor parameter has a matching final field.
Ask for help checking the model structure with this debugging prompt.
The models describe the data. The repository now provides the boundary that supplies that data to the rest of CarePath.
- Return to health_repository.dart in the VS Code editor.
- Replace the temporary repository placeholder with the complete repository code below:
import '../domain/health_models.dart';
abstract interface class HealthRepository {
List<Doctor> getDoctors();
Appointment? get upcomingAppointment;
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
});
}
class LocalHealthRepository implements HealthRepository {
static const _doctors = <Doctor>[
Doctor(
id: 'maya-chen',
name: 'Dr. Maya Chen',
specialty: 'Cardiology',
hospital: 'Harbor Medical Center',
rating: 4.9,
yearsExperience: 12,
bio: 'Focused on clear, preventive heart-care conversations.',
availableDates: ['Mon 2 Nov', 'Tue 3 Nov', 'Thu 5 Nov'],
timeSlots: ['09:00', '10:30', '14:00', '16:30'],
),
Doctor(
id: 'daniel-okafor',
name: 'Dr. Daniel Okafor',
specialty: 'General care',
hospital: 'Riverside Health Clinic',
rating: 4.8,
yearsExperience: 9,
bio: 'Provides practical primary care for adults and families.',
availableDates: ['Mon 2 Nov', 'Wed 4 Nov', 'Fri 6 Nov'],
timeSlots: ['08:30', '11:00', '13:30', '15:00'],
),
Doctor(
id: 'sofia-ramos',
name: 'Dr. Sofia Ramos',
specialty: 'Dermatology',
hospital: 'Northstar Specialist Centre',
rating: 4.7,
yearsExperience: 11,
bio: 'Combines evidence-led skin care with approachable guidance.',
availableDates: ['Tue 3 Nov', 'Thu 5 Nov', 'Sat 7 Nov'],
timeSlots: ['09:30', '12:00', '15:30', '17:00'],
),
];
Appointment? _upcomingAppointment;
@override
List<Doctor> getDoctors() => List.unmodifiable(_doctors);
@override
Appointment? get upcomingAppointment => _upcomingAppointment;
@override
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
}) {
_upcomingAppointment = Appointment(
doctor: doctor,
date: date,
time: time,
);
}
}
How does the repository help?
The HealthRepository interface defines what the UI layer can request. The view model depends on this boundary instead of knowing where the records are stored.
The LocalHealthRepository implementation supplies three fictional records. List.unmodifiable(_doctors) prevents callers from changing the repository list directly.
Keep Demonstration Data Fictional
CarePath uses fictional clinicians and facilities throughout this project. Keep real patient details and real health information out of the local records.
- Save health_repository.dart.
- Confirm the running terminal completes another hot reload without a compilation error.
Repository file not compiling?
- Check that the import path begins with ../domain/ because the repository file sits inside lib/data.
- Check that each Doctor record includes every required model field.
- Check that the _doctors list closes before _upcomingAppointment is declared.
Use this repository debugging prompt if the error remains.
Centralize filtering in the view model
A ChangeNotifier stores the selected specialty outside the widgets. When that value changes, notifyListeners() tells listening screens to rebuild from the filtered doctor list.
- Return to healthcare_view_model.dart in lib/presentation.
- Replace the temporary view model with this repository-backed version:
import 'package:flutter/foundation.dart';
import '../data/health_repository.dart';
import '../domain/health_models.dart';
class HealthcareViewModel extends ChangeNotifier {
HealthcareViewModel(this._repository);
final HealthRepository _repository;
int _selectedTab = 0;
String _selectedSpecialty = 'All';
int get selectedTab => _selectedTab;
String get selectedSpecialty => _selectedSpecialty;
Appointment? get upcomingAppointment => _repository.upcomingAppointment;
List<String> get specialties => <String>[
'All',
..._repository.getDoctors().map((doctor) => doctor.specialty).toSet(),
];
List<Doctor> get doctors {
final allDoctors = _repository.getDoctors();
if (_selectedSpecialty == 'All') return allDoctors;
return allDoctors
.where((doctor) => doctor.specialty == _selectedSpecialty)
.toList(growable: false);
}
void selectTab(int index) {
if (_selectedTab == index) return;
_selectedTab = index;
notifyListeners();
}
void selectSpecialty(String specialty) {
if (_selectedSpecialty == specialty) return;
_selectedSpecialty = specialty;
notifyListeners();
}
}
What does the view model control?
- The _repository field is the view model's only route to doctor and appointment data.
- The specialties getter builds a unique list from the repository records.
- The doctors getter returns every doctor for All or returns records matching the selected specialty.
- The selectSpecialty() method updates state before notifying the discovery screen.
- Save healthcare_view_model.dart.
- Confirm the running terminal reports a successful hot reload.
View model showing type errors?
- Check that both relative imports begin with ../ because the view model sits inside lib/presentation.
- Check that HealthcareViewModel still extends ChangeNotifier.
- Check that selectSpecialty() calls notifyListeners() after changing the selected value.
Ask for focused help with this view-model debugging prompt.
Connect the discovery screens
The data path is ready. A ListenableBuilder can now rebuild the discovery list whenever the view model changes the selected specialty.
- Create doctor_flow.dart inside lib/presentation using the New File button in the Explorer sidebar.
- Add the complete doctor discovery flow to doctor_flow.dart by pasting this code:
import 'package:flutter/material.dart';
import '../domain/health_models.dart';
import 'healthcare_view_model.dart';
class DoctorListScreen extends StatelessWidget {
const DoctorListScreen({required this.viewModel, super.key});
final HealthcareViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Find a doctor')),
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
final doctors = viewModel.doctors;
return ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
'Choose a specialty',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: viewModel.specialties
.map(
(specialty) => ChoiceChip(
label: Text(specialty),
selected:
specialty == viewModel.selectedSpecialty,
onSelected: (_) =>
viewModel.selectSpecialty(specialty),
),
)
.toList(growable: false),
),
const SizedBox(height: 20),
Text('${doctors.length} doctors available'),
const SizedBox(height: 8),
...doctors.map(
(doctor) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _DoctorCard(
doctor: doctor,
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DoctorDetailScreen(
doctor: doctor,
viewModel: viewModel,
),
),
);
},
),
),
),
],
);
},
),
),
);
}
}
class _DoctorCard extends StatelessWidget {
const _DoctorCard({required this.doctor, required this.onTap});
final Doctor doctor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(radius: 28, child: Text(doctor.initials)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
doctor.name,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(doctor.specialty),
Text(
'${doctor.hospital} • ${doctor.rating}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}
class DoctorDetailScreen extends StatelessWidget {
const DoctorDetailScreen({
required this.doctor,
required this.viewModel,
super.key,
});
final Doctor doctor;
final HealthcareViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Doctor details')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
CircleAvatar(radius: 44, child: Text(doctor.initials)),
const SizedBox(height: 16),
Text(
doctor.name,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
doctor.specialty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('${doctor.rating} rating')),
Chip(label: Text('${doctor.yearsExperience} years')),
],
),
const SizedBox(height: 24),
Text(
doctor.hospital,
style: const TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(doctor.bio),
const SizedBox(height: 28),
FilledButton(
onPressed: () {},
child: const Text('Book appointment'),
),
],
),
),
);
}
}
How does discovery react to state?
The ListenableBuilder listens to the same view model used by the app shell. Selecting a ChoiceChip updates the specialty before rebuilding the list from viewModel.doctors.
Each _DoctorCard opens DoctorDetailScreen through a MaterialPageRoute. The booking button marks the next entry point while this step stays focused on discovery.
- Save doctor_flow.dart.
- Confirm the terminal accepts the new file without reporting a Dart syntax error.
Doctor flow showing syntax errors?
- Check that doctor_flow.dart imports both health_models.dart and healthcare_view_model.dart.
- Check that the spread before doctors.map uses three dots.
- Check that DoctorDetailScreen receives both doctor and viewModel.
Use this widget debugging prompt if you still see an error.
The final wiring replaces the temporary SnackBar. It also lets the home screen receive the repository's current appointment value while keeping the empty state visible.
- Return to home_screen.dart in the VS Code editor.
- Add this model import below the Flutter import:
import '../domain/health_models.dart';
Why import the models?
The home screen uses the nullable Appointment type to choose between its empty card and its future confirmed-appointment card.
- Find the HomeScreen constructor and callback fields.
- Add the appointment parameter and field so the section matches this code:
class HomeScreen extends StatelessWidget {
const HomeScreen({
required this.appointment,
required this.onFindDoctor,
required this.onOpenReports,
required this.onOpenProfile,
super.key,
});
final Appointment? appointment;
final VoidCallback onFindDoctor;
final VoidCallback onOpenReports;
final VoidCallback onOpenProfile;
What changes on the home screen?
The nullable field allows HomeScreen to render safely before an appointment exists. The next step supplies a non-null value after booking.
- Find the existing _AppointmentCard inside the home screen's widget list.
- Pass the new field into the card so the line looks like this:
_AppointmentCard(appointment: appointment),
What does this connection provide?
The appointment card now receives its value from the app's shared state. A null value continues to display No appointment booked yet..
- Return to app.dart.
- Add this discovery-flow import beside the other presentation imports:
import 'presentation/doctor_flow.dart';
Why does the app shell need this import?
The shell creates DoctorListScreen when the dashboard action is selected. The import makes that screen available to the navigation callback.
- Find the HomeScreen inside the IndexedStack.
- Replace its temporary SnackBar configuration with this complete HomeScreen block:
HomeScreen(
appointment: viewModel.upcomingAppointment,
onFindDoctor: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) =>
DoctorListScreen(viewModel: viewModel),
),
);
},
onOpenReports: () => viewModel.selectTab(1),
onOpenProfile: () => viewModel.selectTab(2),
),
How does the route connect?
The callback pushes DoctorListScreen onto the navigation stack. Passing the existing view model keeps tab state and specialty state in one object.
- Save home_screen.dart.
- Save app.dart.
- Watch the running terminal until the hot reload completes.
Hot reload failing after the wiring change?
- Check that doctor_flow.dart sits inside lib/presentation.
- Check that HomeScreen receives appointment before the four callback arguments.
- Check that the old SnackBar callback has been removed from app.dart.
Ask for help tracing the imports and constructor arguments with this wiring prompt.
Before you check, predict which doctor cards will remain after you choose Cardiology.
- Return to the CarePath page already open in Safari.
- Select Find a doctor on the blue hero card.
- Select the Cardiology specialty chip.
- Select the card for Dr. Maya Chen.
You should see only Dr. Maya Chen after filtering. Her detail screen should show Cardiology, a 4.9 rating, 12 years, Harbor Medical Center, her bio, and the booking button.
That connection is working. CarePath now turns repository data into a filterable doctor journey without embedding records inside the widgets.
Discovery screen not behaving as expected?
- Check that the dashboard callback pushes DoctorListScreen(viewModel: viewModel).
- Check that every chip calls viewModel.selectSpecialty(specialty).
- Check that the list reads viewModel.doctors inside the ListenableBuilder.
Use this discovery debugging prompt.
✔️ Awesome, I've got everything!
Great. Double check that every edited file is saved. Your Cardiology filter should lead directly to Dr. Maya Chen's detail screen.
ⓧ I'd like to double check the full code
Compare your new model file with this complete version:
class Doctor {
const Doctor({
required this.id,
required this.name,
required this.specialty,
required this.hospital,
required this.rating,
required this.yearsExperience,
required this.bio,
required this.availableDates,
required this.timeSlots,
});
final String id;
final String name;
final String specialty;
final String hospital;
final double rating;
final int yearsExperience;
final String bio;
final List<String> availableDates;
final List<String> timeSlots;
String get initials => name
.split(' ')
.where((part) => part.isNotEmpty && part != 'Dr.')
.take(2)
.map((part) => part[0])
.join();
}
class Appointment {
const Appointment({
required this.doctor,
required this.date,
required this.time,
});
final Doctor doctor;
final String date;
final String time;
}
Compare your repository with this complete version:
import '../domain/health_models.dart';
abstract interface class HealthRepository {
List<Doctor> getDoctors();
Appointment? get upcomingAppointment;
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
});
}
class LocalHealthRepository implements HealthRepository {
static const _doctors = <Doctor>[
Doctor(
id: 'maya-chen',
name: 'Dr. Maya Chen',
specialty: 'Cardiology',
hospital: 'Harbor Medical Center',
rating: 4.9,
yearsExperience: 12,
bio: 'Focused on clear, preventive heart-care conversations.',
availableDates: ['Mon 2 Nov', 'Tue 3 Nov', 'Thu 5 Nov'],
timeSlots: ['09:00', '10:30', '14:00', '16:30'],
),
Doctor(
id: 'daniel-okafor',
name: 'Dr. Daniel Okafor',
specialty: 'General care',
hospital: 'Riverside Health Clinic',
rating: 4.8,
yearsExperience: 9,
bio: 'Provides practical primary care for adults and families.',
availableDates: ['Mon 2 Nov', 'Wed 4 Nov', 'Fri 6 Nov'],
timeSlots: ['08:30', '11:00', '13:30', '15:00'],
),
Doctor(
id: 'sofia-ramos',
name: 'Dr. Sofia Ramos',
specialty: 'Dermatology',
hospital: 'Northstar Specialist Centre',
rating: 4.7,
yearsExperience: 11,
bio: 'Combines evidence-led skin care with approachable guidance.',
availableDates: ['Tue 3 Nov', 'Thu 5 Nov', 'Sat 7 Nov'],
timeSlots: ['09:30', '12:00', '15:30', '17:00'],
),
];
Appointment? _upcomingAppointment;
@override
List<Doctor> getDoctors() => List.unmodifiable(_doctors);
@override
Appointment? get upcomingAppointment => _upcomingAppointment;
@override
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
}) {
_upcomingAppointment = Appointment(
doctor: doctor,
date: date,
time: time,
);
}
}
Compare your view model with this complete version:
import 'package:flutter/foundation.dart';
import '../data/health_repository.dart';
import '../domain/health_models.dart';
class HealthcareViewModel extends ChangeNotifier {
HealthcareViewModel(this._repository);
final HealthRepository _repository;
int _selectedTab = 0;
String _selectedSpecialty = 'All';
int get selectedTab => _selectedTab;
String get selectedSpecialty => _selectedSpecialty;
Appointment? get upcomingAppointment => _repository.upcomingAppointment;
List<String> get specialties => <String>[
'All',
..._repository.getDoctors().map((doctor) => doctor.specialty).toSet(),
];
List<Doctor> get doctors {
final allDoctors = _repository.getDoctors();
if (_selectedSpecialty == 'All') return allDoctors;
return allDoctors
.where((doctor) => doctor.specialty == _selectedSpecialty)
.toList(growable: false);
}
void selectTab(int index) {
if (_selectedTab == index) return;
_selectedTab = index;
notifyListeners();
}
void selectSpecialty(String specialty) {
if (_selectedSpecialty == specialty) return;
_selectedSpecialty = specialty;
notifyListeners();
}
}
Compare your discovery flow with this complete version:
import 'package:flutter/material.dart';
import '../domain/health_models.dart';
import 'healthcare_view_model.dart';
class DoctorListScreen extends StatelessWidget {
const DoctorListScreen({required this.viewModel, super.key});
final HealthcareViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Find a doctor')),
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
final doctors = viewModel.doctors;
return ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
'Choose a specialty',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: viewModel.specialties
.map(
(specialty) => ChoiceChip(
label: Text(specialty),
selected:
specialty == viewModel.selectedSpecialty,
onSelected: (_) =>
viewModel.selectSpecialty(specialty),
),
)
.toList(growable: false),
),
const SizedBox(height: 20),
Text('${doctors.length} doctors available'),
const SizedBox(height: 8),
...doctors.map(
(doctor) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _DoctorCard(
doctor: doctor,
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DoctorDetailScreen(
doctor: doctor,
viewModel: viewModel,
),
),
);
},
),
),
),
],
);
},
),
),
);
}
}
class _DoctorCard extends StatelessWidget {
const _DoctorCard({required this.doctor, required this.onTap});
final Doctor doctor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(radius: 28, child: Text(doctor.initials)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
doctor.name,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(doctor.specialty),
Text(
'${doctor.hospital} • ${doctor.rating}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}
class DoctorDetailScreen extends StatelessWidget {
const DoctorDetailScreen({
required this.doctor,
required this.viewModel,
super.key,
});
final Doctor doctor;
final HealthcareViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Doctor details')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
CircleAvatar(radius: 44, child: Text(doctor.initials)),
const SizedBox(height: 16),
Text(
doctor.name,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
doctor.specialty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('${doctor.rating} rating')),
Chip(label: Text('${doctor.yearsExperience} years')),
],
),
const SizedBox(height: 24),
Text(
doctor.hospital,
style: const TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(doctor.bio),
const SizedBox(height: 28),
FilledButton(
onPressed: () {},
child: const Text('Book appointment'),
),
],
),
),
);
}
}
Doctor discovery is connected from dashboard to detail screen. Next up, you'll turn the booking button into a date-and-time flow that updates the original home screen.
Add Stateful Appointment Booking
Your doctor discovery flow now turns the CarePath dashboard into a working healthcare journey. You can filter fictional doctors before opening a detailed profile.
Browsing alone leaves that journey unfinished. In this step, you will collect a date and time before storing the confirmed appointment for the home screen.
In this step, get ready to:
- Store confirmed appointments through the repository and view model.
- Build date and time selection into the booking screen.
- Return home after confirmation and verify the complete journey.
Store the confirmed appointment
The repository pattern gives appointment data one place to live. The UI can then read the same appointment after the navigation stack returns home.
- In lib/data/health_repository.dart, replace the temporary appointment implementation inside LocalHealthRepository with the following state and booking logic:
Appointment? _upcomingAppointment;
@override
Appointment? get upcomingAppointment => _upcomingAppointment;
@override
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
}) {
_upcomingAppointment = Appointment(
doctor: doctor,
date: date,
time: time,
);
}
What does this code do?
- The nullable _upcomingAppointment field keeps the repository empty until a booking is confirmed.
- The upcomingAppointment getter exposes the current value without exposing the private field.
- The bookAppointment() method creates an immutable Appointment from the selected doctor, date, and time.
- Save lib/data/health_repository.dart.
✔️ Awesome, I've got everything!
Great. Your local repository can now store one upcoming appointment.
ⓧ I'd like to double check the full code
Compare your repository with the complete file below.
import '../domain/health_models.dart';
abstract interface class HealthRepository {
List<Doctor> getDoctors();
Appointment? get upcomingAppointment;
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
});
}
class LocalHealthRepository implements HealthRepository {
static const _doctors = <Doctor>[
Doctor(
id: 'maya-chen',
name: 'Dr. Maya Chen',
specialty: 'Cardiology',
hospital: 'Harbor Medical Center',
rating: 4.9,
yearsExperience: 12,
bio: 'Focused on clear, preventive heart-care conversations.',
availableDates: ['Mon 2 Nov', 'Tue 3 Nov', 'Thu 5 Nov'],
timeSlots: ['09:00', '10:30', '14:00', '16:30'],
),
Doctor(
id: 'daniel-okafor',
name: 'Dr. Daniel Okafor',
specialty: 'General care',
hospital: 'Riverside Health Clinic',
rating: 4.8,
yearsExperience: 9,
bio: 'Provides practical primary care for adults and families.',
availableDates: ['Mon 2 Nov', 'Wed 4 Nov', 'Fri 6 Nov'],
timeSlots: ['08:30', '11:00', '13:30', '15:00'],
),
Doctor(
id: 'sofia-ramos',
name: 'Dr. Sofia Ramos',
specialty: 'Dermatology',
hospital: 'Northstar Specialist Centre',
rating: 4.7,
yearsExperience: 11,
bio: 'Combines evidence-led skin care with approachable guidance.',
availableDates: ['Tue 3 Nov', 'Thu 5 Nov', 'Sat 7 Nov'],
timeSlots: ['09:30', '12:00', '15:30', '17:00'],
),
];
Appointment? _upcomingAppointment;
@override
List<Doctor> getDoctors() => List.unmodifiable(_doctors);
@override
Appointment? get upcomingAppointment => _upcomingAppointment;
@override
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
}) {
_upcomingAppointment = Appointment(
doctor: doctor,
date: date,
time: time,
);
}
}
A repository mutation only changes stored data. ChangeNotifier carries that change into the listening widgets that render CarePath.
- In lib/presentation/healthcare_view_model.dart, add the following method below selectSpecialty().
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
}) {
_repository.bookAppointment(doctor: doctor, date: date, time: time);
_selectedTab = 0;
notifyListeners();
}
How does the update reach home?
- The view model passes the completed choice to _repository.bookAppointment().
- Setting _selectedTab to 0 prepares the Home destination.
- Calling notifyListeners() rebuilds the listening app shell with the stored appointment.
- Save lib/presentation/healthcare_view_model.dart.
✔️ Awesome, I've got everything!
Your view model now coordinates the repository update and the return to Home.
ⓧ I'd like to double check the full code
Compare your view model with the complete file below.
import 'package:flutter/foundation.dart';
import '../data/health_repository.dart';
import '../domain/health_models.dart';
class HealthcareViewModel extends ChangeNotifier {
HealthcareViewModel(this._repository);
final HealthRepository _repository;
int _selectedTab = 0;
String _selectedSpecialty = 'All';
int get selectedTab => _selectedTab;
String get selectedSpecialty => _selectedSpecialty;
Appointment? get upcomingAppointment => _repository.upcomingAppointment;
List<String> get specialties => <String>[
'All',
..._repository.getDoctors().map((doctor) => doctor.specialty).toSet(),
];
List<Doctor> get doctors {
final allDoctors = _repository.getDoctors();
if (_selectedSpecialty == 'All') return allDoctors;
return allDoctors
.where((doctor) => doctor.specialty == _selectedSpecialty)
.toList(growable: false);
}
void selectTab(int index) {
if (_selectedTab == index) return;
_selectedTab = index;
notifyListeners();
}
void selectSpecialty(String specialty) {
if (_selectedSpecialty == specialty) return;
_selectedSpecialty = specialty;
notifyListeners();
}
void bookAppointment({
required Doctor doctor,
required String date,
required String time,
}) {
_repository.bookAppointment(doctor: doctor, date: date, time: time);
_selectedTab = 0;
notifyListeners();
}
}
Before you check the state layer, do you expect the analyzer to accept the new repository and view model methods?
- Check the updated state layer by running the analyzer in a terminal session inside healthcare_app:
flutter analyze
The analysis completes without issues. This proves the repository contract and view model method agree on every booking value.
Does the state layer fail analysis?
- Check that bookAppointment() uses the same required parameters in the repository interface, local repository, and view model.
- Check that _upcomingAppointment is declared inside LocalHealthRepository.
- Check that each closing brace remains inside its original class.
Help me compare my repository and view model booking methods.
Build the date and time selections
The booking screen owns two temporary choices because they only matter while the user is completing the form. A StatefulWidget keeps those values local until both selections are ready for confirmation.
- In lib/presentation/doctor_flow.dart, add the following widget below DoctorDetailScreen.
class BookingScreen extends StatefulWidget {
const BookingScreen({
required this.doctor,
required this.viewModel,
super.key,
});
final Doctor doctor;
final HealthcareViewModel viewModel;
@override
State<BookingScreen> createState() => _BookingScreenState();
}
Why pass both objects?
The selected Doctor supplies the available dates and times. The shared HealthcareViewModel receives the final choice.
The state class is longer because it contains two groups of choices. Add the next four fragments directly below BookingScreen as one continuous class.
- Start _BookingScreenState with its selection fields and layout shell by adding this first fragment:
class _BookingScreenState extends State<BookingScreen> {
String? _selectedDate;
String? _selectedTime;
@override
Widget build(BuildContext context) {
final canConfirm = _selectedDate != null && _selectedTime != null;
return Scaffold(
appBar: AppBar(title: const Text('Book appointment')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
widget.doctor.name,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 24),
const Text(
'Choose a date',
style: TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
What starts the booking state?
The nullable fields begin without a choice. The canConfirm value becomes true only when both fields contain a selection.
- Continue the children list with the date choices by adding this fragment:
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.doctor.availableDates
.map(
(date) => ChoiceChip(
label: Text(date),
selected: _selectedDate == date,
onSelected: (_) => setState(() => _selectedDate = date),
),
)
.toList(growable: false),
),
const SizedBox(height: 24),
const Text(
'Choose a time',
style: TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
How does date selection work?
Each ChoiceChip compares its date with _selectedDate. Calling setState() redraws the group with one selected chip.
- Continue the same children list with the time choices by adding this fragment:
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.doctor.timeSlots
.map(
(time) => ChoiceChip(
label: Text(time),
selected: _selectedTime == time,
onSelected: (_) => setState(() => _selectedTime = time),
),
)
.toList(growable: false),
),
const SizedBox(height: 32),
Why use the same pattern twice?
The time chips follow the same single-selection behavior as the dates. Keeping each value separate makes the confirmation requirement explicit.
- Finish _BookingScreenState with the confirmation action by adding this final fragment:
FilledButton(
onPressed: canConfirm
? () {
widget.viewModel.bookAppointment(
doctor: widget.doctor,
date: _selectedDate!,
time: _selectedTime!,
);
Navigator.popUntil(context, (route) => route.isFirst);
}
: null,
child: const Text('Confirm appointment'),
),
],
),
),
);
}
}
How does confirmation stay safe?
A null onPressed disables the button until canConfirm becomes true. The selected values are therefore available before the booking method receives them.
After storage, Navigator.popUntil removes the discovery routes until the first route becomes visible again.
- Inside DoctorDetailScreen, replace the existing Book appointment button with this completed button:
FilledButton(
onPressed: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => BookingScreen(
doctor: doctor,
viewModel: viewModel,
),
),
);
},
child: const Text('Book appointment'),
),
What completes the route?
The detail screen passes its current doctor into BookingScreen. It also passes the existing view model so the booking returns to the same app state.
- Save lib/presentation/doctor_flow.dart.
- Trigger hot reload from the Flutter process that is already running.
- Switch back to Safari from earlier.
- Open any fictional doctor's details.
- Select Book appointment.
You will see the doctor's name above separate date and time chip groups. The Confirm appointment button starts disabled.
Does the booking screen fail to open?
- Check that BookingScreen sits outside the closing brace of DoctorDetailScreen.
- Check that the route passes both doctor and viewModel.
- Check that all four state-class fragments form one continuous _BookingScreenState class.
Help me fix my Flutter booking screen structure.
✔️ Awesome, I've got everything!
Your detail screen now opens a stateful booking form with single-select date and time controls.
ⓧ I'd like to double check the full code
Compare your complete doctor flow with the file below.
import 'package:flutter/material.dart';
import '../domain/health_models.dart';
import 'healthcare_view_model.dart';
class DoctorListScreen extends StatelessWidget {
const DoctorListScreen({required this.viewModel, super.key});
final HealthcareViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Find a doctor')),
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
final doctors = viewModel.doctors;
return ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
'Choose a specialty',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: viewModel.specialties
.map(
(specialty) => ChoiceChip(
label: Text(specialty),
selected:
specialty == viewModel.selectedSpecialty,
onSelected: (_) =>
viewModel.selectSpecialty(specialty),
),
)
.toList(growable: false),
),
const SizedBox(height: 20),
Text('${doctors.length} doctors available'),
const SizedBox(height: 8),
...doctors.map(
(doctor) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _DoctorCard(
doctor: doctor,
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DoctorDetailScreen(
doctor: doctor,
viewModel: viewModel,
),
),
);
},
),
),
),
],
);
},
),
),
);
}
}
class _DoctorCard extends StatelessWidget {
const _DoctorCard({required this.doctor, required this.onTap});
final Doctor doctor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(radius: 28, child: Text(doctor.initials)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
doctor.name,
style: const TextStyle(fontWeight: FontWeight.w700),
),
Text(doctor.specialty),
Text(
'${doctor.hospital} • ${doctor.rating}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}
class DoctorDetailScreen extends StatelessWidget {
const DoctorDetailScreen({
required this.doctor,
required this.viewModel,
super.key,
});
final Doctor doctor;
final HealthcareViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Doctor details')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
CircleAvatar(radius: 44, child: Text(doctor.initials)),
const SizedBox(height: 16),
Text(
doctor.name,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
doctor.specialty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('${doctor.rating} rating')),
Chip(label: Text('${doctor.yearsExperience} years')),
],
),
const SizedBox(height: 24),
Text(
doctor.hospital,
style: const TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(doctor.bio),
const SizedBox(height: 28),
FilledButton(
onPressed: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => BookingScreen(
doctor: doctor,
viewModel: viewModel,
),
),
);
},
child: const Text('Book appointment'),
),
],
),
),
);
}
}
class BookingScreen extends StatefulWidget {
const BookingScreen({
required this.doctor,
required this.viewModel,
super.key,
});
final Doctor doctor;
final HealthcareViewModel viewModel;
@override
State<BookingScreen> createState() => _BookingScreenState();
}
class _BookingScreenState extends State<BookingScreen> {
String? _selectedDate;
String? _selectedTime;
@override
Widget build(BuildContext context) {
final canConfirm = _selectedDate != null && _selectedTime != null;
return Scaffold(
appBar: AppBar(title: const Text('Book appointment')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
widget.doctor.name,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 24),
const Text(
'Choose a date',
style: TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.doctor.availableDates
.map(
(date) => ChoiceChip(
label: Text(date),
selected: _selectedDate == date,
onSelected: (_) => setState(() => _selectedDate = date),
),
)
.toList(growable: false),
),
const SizedBox(height: 24),
const Text(
'Choose a time',
style: TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.doctor.timeSlots
.map(
(time) => ChoiceChip(
label: Text(time),
selected: _selectedTime == time,
onSelected: (_) => setState(() => _selectedTime = time),
),
)
.toList(growable: false),
),
const SizedBox(height: 32),
FilledButton(
onPressed: canConfirm
? () {
widget.viewModel.bookAppointment(
doctor: widget.doctor,
date: _selectedDate!,
time: _selectedTime!,
);
Navigator.popUntil(context, (route) => route.isFirst);
}
: null,
child: const Text('Confirm appointment'),
),
],
),
),
);
}
}
Confirm the complete appointment journey
The navigation stack now connects discovery, details, booking, and Home. The final check proves that local form state becomes shared appointment state.
- Switch back to Safari from earlier.
- Select Home in the navigation bar.
- Select Find a doctor.
- Select Cardiology.
- Open Dr. Maya Chen.
- Select Book appointment.
- Select Mon 2 Nov.
- Select 09:00.
Before you confirm, where do you expect the app to return after the appointment is stored?
- Select Confirm appointment.
You will return to the Home dashboard. The Upcoming appointment card will show Maya Chen's initials, her name, and the selected slot.
That completes the full product loop. CarePath now carries a user choice from discovery back to a visible dashboard update.
Before you run the final check, do you expect any Dart issues after completing the browser journey?
- Create a second terminal session inside the running healthcare_app project.
- Analyze the completed project by running this command:
flutter analyze
The analyzer completes without issues. Your browser journey and static analysis now confirm the booking flow from two different angles.
Does confirmation fail to update Home?
- Check that both a date and time are selected before pressing the confirmation button.
- Check that HealthcareViewModel.bookAppointment() calls the repository before notifyListeners().
- Check that Navigator.popUntil runs after the booking method.
Help me trace why my confirmed appointment does not appear on Home.
Strong finish. CarePath now supports discovery, date and time selection, confirmation, and a reactive appointment card on Home.
Secret mission
Add Live Doctor Search
Specialty chips work well with a short doctor catalog. In this mission, you will add live name, specialty, and hospital search while keeping the filtering logic inside the view model.
Clean Up Your Resources
Clean Up Your Resources
CarePath runs locally through Flutter on your Mac without ongoing charges. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The running local web server that keeps the CarePath preview available in Safari.
- The healthcare_app folder containing the complete CarePath source code.
Keep everything running
No action is needed. This option suits continued work on the CarePath interface.
- Leave the Terminal process running while you continue testing CarePath.
- Keep the Safari preview open at the local URL for quick checks.
- Retain the healthcare_app folder with the booking flow and live doctor search.
Pause - I'll come back to this later
Pausing frees local memory while keeping every source file ready for later.
- Switch back to the Terminal window that is running the local web server.
- Press Control+C to stop the server.
The command prompt returns when the server stops. Safari can no longer load the local preview.
- Keep the healthcare_app folder on your Mac for the next time you continue the project.
Delete - I don't want to use this again
Deleting removes the running preview and every local CarePath project file.
Deletion is permanent
Deleting healthcare_app removes the complete project from your Mac. Your Flutter SDK and Visual Studio Code installation stay untouched.
- Switch back to the Terminal window that is running the local web server.
- Press Control+C to stop the server.
- Move above the project folder and delete it by running these commands:
cd ..
rm -rf healthcare_app
What do these commands remove?
- The first line moves Terminal one folder above the project.
- The second line removes the healthcare_app folder with everything inside it.
That is the cleanup complete. Your local CarePath workspace is removed from the Mac.
Nice Work!
Nice Work!
You did it! CarePath now delivers a polished healthcare journey from doctor discovery to confirmed booking.
You've learned how to:
- Shape a professional dashboard with a reusable Material 3 theme. Preserve each primary destination with responsive navigation.
- Keep fictional doctor data behind the repository pattern. Drive specialty filtering through a dedicated view model. Open each result as a full doctor detail screen.
- Complete a stateful booking flow that captures a date and time. Show the confirmed appointment on the home dashboard through reactive state updates.
- Secret Mission: Extend doctor discovery with live search across doctor names, specialties, and hospitals while preserving the selected specialty filter.
Ready to quiz yourself?