Create a virtual museum tour app with room-by-room navigation using PageView, interactive floor plan hotspots using Stack with positioned GestureDetectors, exhibit detail pages with image galleries and audio guides via the just_audio package, and a favorites bookmark system. Use InteractiveViewer for pinch-to-zoom on floor plans so hotspots remain tappable on small screens.
Building a Virtual Museum Tour App in FlutterFlow
Virtual museum tours let users explore exhibits remotely with rich media and audio guides. This tutorial builds a self-paced tour app with room navigation, interactive floor plans with tappable exhibit markers, image galleries, audio narration, and a favorites system — perfect for museums, galleries, and heritage sites.
Prerequisites
- A FlutterFlow project on the Pro plan (Custom Widget required for audio)
- Firebase Storage for hosting exhibit images and audio guide files
- Firestore database for exhibit and room data
- Floor plan images for each museum room (PNG or JPG)
Step-by-step guide
Create the Firestore schema for rooms and exhibits
Create the Firestore schema for rooms and exhibits
Create a `rooms` collection with fields: name (String), floorPlanImageUrl (String pointing to Firebase Storage), order (Integer for room sequence), description (String). Create an `exhibits` collection with fields: title (String), description (String), imageUrls (List of Strings for the gallery), audioGuideUrl (String pointing to an MP3 in Storage), roomId (String referencing the room), positionX (Double, percentage 0-1 of floor plan width), positionY (Double, percentage 0-1 of floor plan height), category (String: painting/sculpture/artifact/installation). Add a `favorites` subcollection under each user document with fields: exhibitId (String), savedAt (Timestamp). Register all collections in FlutterFlow's Data panel.
Expected result: Collections for rooms, exhibits, and user favorites are set up with exhibit positions stored as relative coordinates.
Build the room navigation with PageView and floor plan display
Build the room navigation with PageView and floor plan display
Create a MuseumTour page. Add a PageView widget at the top half of the screen. Bind it to a Backend Query on the rooms collection ordered by the order field. Each page represents one room. Inside each page, add a Stack widget. The first child of the Stack is an Image widget displaying the room's floorPlanImageUrl. Wrap the entire Stack in an InteractiveViewer Custom Widget to enable pinch-to-zoom so users can zoom into the floor plan on mobile devices. Below the PageView, add a Row with the room name Text and left/right arrow IconButtons for manual navigation between rooms. Add a SmoothPageIndicator (dots) to show the current room position.
Expected result: Users swipe through museum rooms in a PageView, each showing the floor plan image with pinch-to-zoom capability and room name below.
Add tappable exhibit hotspots on the floor plan
Add tappable exhibit hotspots on the floor plan
For each room's Stack, query the exhibits collection filtered by roomId matching the current room. For each exhibit, add a Positioned widget inside the Stack. Set left to positionX times the Stack width and top to positionY times the Stack height. Inside each Positioned, add a GestureDetector wrapping a Container styled as a colored circle (20x20 pixels, with a pulsing animation or a small exhibit icon). On tap, navigate to the ExhibitDetail page passing the exhibitId. Since FlutterFlow's visual builder does not support dynamic Positioned widgets natively, create a Custom Widget that takes the floor plan URL and a list of exhibit hotspot data as parameters, then renders the Stack with Image and Positioned GestureDetectors programmatically.
1// Custom Widget: FloorPlanWithHotspots2import 'package:flutter/material.dart';34class FloorPlanWithHotspots extends StatelessWidget {5 final double width;6 final double height;7 final String floorPlanUrl;8 final List<Map<String, dynamic>> hotspots;9 final Function(String exhibitId) onHotspotTap;1011 const FloorPlanWithHotspots({12 Key? key,13 required this.width,14 required this.height,15 required this.floorPlanUrl,16 required this.hotspots,17 required this.onHotspotTap,18 }) : super(key: key);1920 @override21 Widget build(BuildContext context) {22 return InteractiveViewer(23 minScale: 1.0,24 maxScale: 3.0,25 child: SizedBox(26 width: width,27 height: height,28 child: Stack(29 children: [30 Image.network(floorPlanUrl,31 width: width, height: height, fit: BoxFit.contain),32 ...hotspots.map((h) => Positioned(33 left: (h['positionX'] as double) * width,34 top: (h['positionY'] as double) * height,35 child: GestureDetector(36 onTap: () => onHotspotTap(h['exhibitId']),37 child: Container(38 width: 28, height: 28,39 decoration: BoxDecoration(40 color: Colors.red.withOpacity(0.8),41 shape: BoxShape.circle,42 border: Border.all(43 color: Colors.white, width: 2),44 ),45 child: const Icon(Icons.museum,46 color: Colors.white, size: 14),47 ),48 ),49 )),50 ],51 ),52 ),53 );54 }55}Expected result: Colored hotspot markers appear on the floor plan at each exhibit's position. Tapping a marker navigates to that exhibit's detail page.
Create the exhibit detail page with image gallery and audio guide
Create the exhibit detail page with image gallery and audio guide
Create an ExhibitDetail page that receives an exhibitId parameter. Query the exhibits collection for this document. At the top, add a PageView bound to the exhibit's imageUrls array — each page shows a full-width Image with a SmoothPageIndicator below for gallery navigation. Below the gallery, display the exhibit title as a heading Text, category as a styled Chip, and description in a scrollable Text widget. For the audio guide, add a Custom Widget using the just_audio package. Pass the audioGuideUrl as a parameter. The widget renders play/pause IconButton, a Slider for seeking, and a duration Text. Add a heart IconButton in the AppBar for bookmarking — on tap, create or delete a document in the user's favorites subcollection.
Expected result: The exhibit page shows a swipeable image gallery, descriptive text, an audio guide player with play/pause and seek, and a favorite bookmark button.
Build the self-guided tour route and favorites list
Build the self-guided tour route and favorites list
Add a Guided Tour tab or button on the MuseumTour page. Query all exhibits ordered by roomId then by a tourOrder field (add this to the schema). Display them in a numbered ListView as a suggested walking route. Each item shows the exhibit title, room name, a thumbnail image, and a checkmark that the user taps when they have visited. Track visited exhibits in a Page State list of exhibitIds. Show a LinearPercentIndicator at the top: visitedCount / totalExhibits. For the favorites, add a Favorites page with a GridView bound to the current user's favorites subcollection. Each card shows the exhibit thumbnail and title. Tap to navigate to the exhibit detail.
Expected result: Users can follow a suggested tour route with progress tracking and maintain a personal list of favorite exhibits.
Complete working example
1// Custom Widget: FloorPlanWithHotspots2// Renders floor plan image with tappable exhibit markers3import 'package:flutter/material.dart';45class FloorPlanWithHotspots extends StatelessWidget {6 final double width;7 final double height;8 final String floorPlanUrl;9 final List<Map<String, dynamic>> hotspots;10 final Function(String exhibitId) onHotspotTap;1112 const FloorPlanWithHotspots({13 Key? key, required this.width, required this.height,14 required this.floorPlanUrl, required this.hotspots,15 required this.onHotspotTap,16 }) : super(key: key);1718 @override19 Widget build(BuildContext context) {20 return InteractiveViewer(21 minScale: 1.0, maxScale: 3.0,22 child: SizedBox(width: width, height: height,23 child: Stack(children: [24 Image.network(floorPlanUrl,25 width: width, height: height, fit: BoxFit.contain),26 ...hotspots.map((h) => Positioned(27 left: (h['positionX'] as double) * width,28 top: (h['positionY'] as double) * height,29 child: GestureDetector(30 onTap: () => onHotspotTap(h['exhibitId']),31 child: Container(32 width: 28, height: 28,33 decoration: BoxDecoration(34 color: Colors.red.withOpacity(0.8),35 shape: BoxShape.circle,36 border: Border.all(color: Colors.white, width: 2)),37 child: const Icon(Icons.museum, color: Colors.white, size: 14),38 ),39 ),40 )),41 ]),42 ),43 );44 }45}4647// Custom Widget: AudioGuidePlayer48// Pubspec: just_audio: ^0.9.3649import 'package:just_audio/just_audio.dart';5051class AudioGuidePlayer extends StatefulWidget {52 final double width;53 final double height;54 final String audioUrl;55 const AudioGuidePlayer({56 Key? key, required this.width, required this.height,57 required this.audioUrl,58 }) : super(key: key);59 @override60 State<AudioGuidePlayer> createState() => _AudioGuidePlayerState();61}6263class _AudioGuidePlayerState extends State<AudioGuidePlayer> {64 final _player = AudioPlayer();65 @override66 void initState() { super.initState(); _player.setUrl(widget.audioUrl); }67 @override68 void dispose() { _player.dispose(); super.dispose(); }6970 @override71 Widget build(BuildContext context) {72 return SizedBox(width: widget.width, child: Row(children: [73 StreamBuilder<bool>(74 stream: _player.playingStream,75 builder: (_, snap) {76 final playing = snap.data ?? false;77 return IconButton(78 icon: Icon(playing ? Icons.pause_circle : Icons.play_circle, size: 40),79 onPressed: () => playing ? _player.pause() : _player.play(),80 );81 }),82 Expanded(child: StreamBuilder<Duration>(83 stream: _player.positionStream,84 builder: (_, snap) {85 final pos = snap.data ?? Duration.zero;86 final dur = _player.duration ?? Duration.zero;87 return Slider(88 value: pos.inSeconds.toDouble(),89 max: dur.inSeconds.toDouble().clamp(1, 9999),90 onChanged: (v) => _player.seek(Duration(seconds: v.toInt())));91 })),92 ]));93 }94}Common mistakes
Why it's a problem: Using a single large floor plan image with tiny hotspots that are impossible to tap on mobile
How to avoid: Wrap the floor plan in an InteractiveViewer widget that enables pinch-to-zoom. Make hotspot markers at least 28x28 pixels so they remain tappable even before zooming.
Why it's a problem: Storing hotspot positions as absolute pixel coordinates
How to avoid: Store positions as percentages (0.0 to 1.0) of the image dimensions. Calculate actual position as positionX * containerWidth and positionY * containerHeight.
Why it's a problem: Creating a new AudioPlayer instance every time the exhibit page opens
How to avoid: Dispose the AudioPlayer in the widget's dispose() method. If you want audio to persist across pages, lift the player to a global singleton in App State.
Best practices
- Use InteractiveViewer for pinch-to-zoom on floor plans to make hotspots accessible on small screens
- Store hotspot coordinates as percentages for responsive positioning across all device sizes
- Add a visual pulse animation to hotspot markers so users know they are tappable
- Preload exhibit images using cached_network_image for smooth gallery swiping
- Dispose AudioPlayer instances when navigating away to prevent memory leaks and audio overlap
- Show a tour progress indicator so users know how much of the museum they have explored
- Add accessibility features: text-to-speech for exhibit descriptions for visually impaired users
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a virtual museum tour app in FlutterFlow. I need room-by-room navigation with PageView, interactive floor plans with tappable exhibit hotspots using Stack and GestureDetector, exhibit detail pages with image galleries and audio guides using just_audio, and a favorites system. Show me the Custom Widget code for the floor plan with hotspots and the audio player.
Create a museum tour page with a PageView of rooms. Each room shows a floor plan image with colored circle markers for exhibits. Tapping a marker navigates to an exhibit detail page with image gallery, description, and an audio playback bar.
Frequently asked questions
Can I add 360-degree panoramic views of museum rooms?
Yes. Use a Custom Widget with the panorama_viewer or photo_view package to display 360-degree images. Embed it in the room page alongside or instead of the static floor plan.
How do I add multiple languages for exhibit descriptions and audio guides?
Store descriptions as a map keyed by language code: {en: 'English text', es: 'Spanish text'}. Store separate audioGuideUrls per language. Read the user's selected language from App State to display the correct version.
Can visitors track which exhibits they have already viewed?
Yes. Store viewed exhibit IDs in a Page State list or Firestore subcollection. Change the hotspot color from red to green for visited exhibits and show a progress percentage.
How do I handle offline mode for the tour?
Pre-download exhibit images and audio files to local storage when the user starts the tour. Use cached_network_image for images and just_audio's caching for audio files. Store exhibit data in local App State.
Can I add augmented reality features to the museum tour?
FlutterFlow has limited AR support. For AR exhibit overlays, you would need a Custom Widget using the ar_flutter_plugin package or export the project and integrate ARCore/ARKit natively.
Can RapidDev help build a museum tour app with advanced features?
Yes. RapidDev can implement 360-degree panoramas, AR exhibit overlays, multi-language support, offline mode, accessibility features, ticketing integration, and analytics for exhibit engagement.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation