A modern Flutter movie discovery app. Browse trending films, explore genres, search, build a personal watchlist, and dive into rich movie details all powered by TMDB.
π New here? Start with the API key. Popcorn needs a free TMDB v4 read-access token. I wrote a step-by-step guide that walks you through the whole thing, even if you haven't created the project yet: How to get a TMDB API key (even if your project doesn't exist yet) β Medium
Status: β Feature-complete across the five core flows β splash, onboarding, home discovery, movie detail, search, and watchlist. Focus now is polish + tests.
Custom iOS launch icons, generated from a single source. I wrote a short reminder on how I set this up end-to-end:
How to change Flutter app icons (iOS & Android) β a quick reminder β Medium
Animated splash reads the onboarding flag from Hive and routes accordingly. Returning users skip straight to Home.
Auto-advancing hero carousel (6s cadence, peek of next card, pauses on user touch) with a subtle radial rose glow behind. Three horizontal sections (Popular, Top rated) and a 2-column New releases grid with its own NEW pill + matching shimmer silhouette. Pull-to-refresh re-hits all endpoints. See all β pushes a dedicated Movies List screen with proper loading/error/empty branching.
Tap any poster β Hero-animated transition into a cinematic detail screen. Backdrop header + overlapping poster, stats row (TMDB rating / likes / % liked it), expandable overview, genre pills, horizontal cast avatars (gold ring on lead, initial-letter fallback for missing photos), and a "More like this" row that reuses the Home MovieCard. "Add to Watchlist" is outlined when unsaved, filled gradient when saved. Three parallel TMDB calls (/movie/:id, /credits, /similar) load each section independently.
Three distinct modes driven by a single SearchBloc:
- Idle β an 18-genre grid where each tile uses a real movie backdrop fetched from
/discover/movie?with_genres=β¦. A sequential + deduplicated fetch makes sure each genre shows a unique blockbuster, cached forever in Hive so subsequent opens are instant. - Typing β 400ms debounced search via
stream_transform(debounce.switchMap) so stale requests can't overwrite fresh results. Each row is a denseSearchResultItem(poster, title, year Β· genre, rating, 2-line overview, inline watchlist toggle). - Genre-filtered β tap any tile β shows that genre's popular movies with an
ActiveFilterChipto clear back to idle.
Empty state uses problem_popcorn.png; network failure renders an inline retry with connection_popcorn.png.
2-column poster grid with staggered fade+scale entry animation. Each poster carries the same Hero tag (movie_poster_${id}) as Home's MovieCard, so navigating Watchlist β Detail gets a shared-element transition too. Tap the bookmark chip to remove, with haptic feedback. Persisted via Hive across app restarts.
- π¬ Discover β Trending, Popular, Top Rated sections + a 2-col New Releases grid, all from live TMDB
- π Auto-advancing hero β Swipeable top-5 with peek of neighbors, pauses 4s after user interaction
- π Search β Backdrop-driven genre grid + 400ms debounced text search + genre filter pill
- ποΈ Detail β Backdrop parallax, hero-animated poster, stats, expandable overview, cast row, similar movies, inline watchlist CTA
- π Watchlist β Add/remove from anywhere (Home card, hero, search results, detail), saved locally in Hive, persists across restarts, shared hero transition into detail
- β¨ Splash + Onboarding β Animated intro for first-time users; flag stored in Hive so returning users go straight home
- π¨ Premium dark UI β Fraunces + Inter typography, cinema-inspired palette (rose + gold), frosted-glass bottom nav, radial ambient glow on the hero
- π± Responsive layouts β Horizontal sections, vertical poster grids, intrinsic-height rows, matching shimmer silhouettes to avoid layout shift
- π Pull-to-refresh on Home
- π§ Smart caching β App-wide Bloc singletons for Home/Watchlist keep state across navigation; Hive-cached genre backdrops never re-fetch; factory-scoped blocs for Detail/Search so each entry starts fresh
| Package | Purpose |
|---|---|
flutter_bloc / bloc |
State management (sealed events + single state per slice) |
go_router |
Declarative routing, ShellRoute for bottom nav, deep-linking |
get_it |
Service locator / dependency injection |
dio |
HTTP with interceptors (bearer token, logging, error mapping) |
hive + hive_flutter |
Local NoSQL persistence (onboarding flag, watchlist, genre backdrops cache) |
flutter_dotenv |
Environment variable loader |
cached_network_image |
Network image caching + shimmer placeholders |
shimmer |
Loading state placeholders |
flutter_staggered_animations |
Grid/list entry animations (genre grid, watchlist, search results) |
stream_transform |
Debounced + switch-mapped search input |
equatable |
Value equality for events/states/entities |
build_runner, hive_generator, bloc_test, mocktail
- β
injectableβ manualgetIt.registerLazySingleton/registerFactoryis readable and sufficient - β
fpdartβ we ship our own tiny sealedEither<L, R>inlib/core/types/ - β
google_fontsβ Fraunces + Inter bundled as local.ttfassets for smaller dependency surface - β
share_plusβ share button explicitly out of scope for MVP
Both bundled locally under assets/fonts/.
Popcorn follows Clean Architecture pragmatically β full data / domain / presentation slices for feature-heavy modules with real business logic, lightweight storage classes for simple UI/app state.
Does this feature have real domain concepts and rules?
- Yes (Home, Detail, Search, Watchlist) β full slice
- No (onboarding flag, future settings flags) β single class under
lib/core/storage/
Domain (pure Dart)
βββ entities/{movie_detail,cast_member}.dart Equatable
βββ repositories/detail_repository.dart abstract contract
βββ usecases/{get_movie_detail,get_movie_credits,get_similar_movies}.dart
Data (infrastructure)
βββ models/{movie_detail_model,cast_member_model}.dart fromJson
βββ datasources/detail_remote_datasource.dart Dio calls
βββ repositories/detail_repository_impl.dart wraps calls with shared guard<T>()
Presentation (Flutter)
βββ bloc/detail_{event,state,bloc}.dart per-section MovieStatus, parallel dispatch
βββ screens/detail_screen.dart SingleChildScrollView + Stack for the overlap
βββ widgets/
βββ detail_backdrop_header.dart
βββ detail_poster_title.dart
βββ detail_stats_row.dart
βββ detail_actions.dart
βββ detail_overview.dart
βββ detail_genres.dart
βββ detail_cast_section.dart / cast_avatar.dart
βββ detail_similar_section.dart
- Sealed events + single state per feature. Each feature has one
Stateclass with per-slicestatusfields (MovieStatus.initial/loading/success/failure) β avoids state explosion. - App-wide lazy singleton Blocs for Home + Watchlist. State persists across navigation β returning to Home sees cached data, no re-fetch. Registered in
get_it, injected viaMultiBlocProviderinmain.dart, mounted withBlocProvider.valueso the singleton isn't closed on widget dispose. - Screen-scoped factory Blocs for Detail + Search. Each
/movie/:idpush creates a freshDetailBloc; each/searchopen creates a freshSearchBloc. Transient state, no leak risk. - Event transformers for debounce.
SearchBlocusesstream_transform'sdebounce(400ms).switchMapso a burst of keystrokes collapses to one request, and in-flight stale requests get cancelled.
Typed Failure (for Either) and matching Exception hierarchies live in lib/core/error/failures.dart β server / network / not-found / unauthenticated / cache. DioClient's interceptor maps DioException β these exceptions, and every repository funnels calls through guard<T>() in lib/core/error/guard.dart so exceptions become Left(Failure) at the domain boundary. UI code never sees a throw.
go_router with a ShellRoute wrapping / and /watchlist in a shared glass nav shell. Other routes are top-level and render full-screen.
initialLocation: '/splash',
ShellRoute β GlassNavShell
βββ / β HomeScreen
βββ /watchlist β WatchlistScreen
(top-level)
βββ /splash β SplashScreen
βββ /onboarding β OnboardingScreen
βββ /search β SearchScreen
βββ /movie/:id β DetailScreen
βββ /movies/:type β MoviesListScreen // "See all" destinationlib/
βββ core/
β βββ constants/ # Top-level const strings (URLs, image sizes, Hive keys)
β βββ di/injection.dart # get_it wiring (singletons + factories)
β βββ error/
β β βββ failures.dart # Failure + Exception hierarchies
β β βββ guard.dart # guard<T>(fetch) β shared dataβdomain boundary
β βββ network/ # DioClient, ApiEndpoints
β βββ router/ # GoRouter, GlassNavShell
β βββ storage/ # Simple Hive wrappers (onboarding flag)
β βββ theme/ # Colors, text styles, M3 theme
β βββ types/either.dart # Sealed Either<L, R> + Left/Right
β βββ usecase/usecase.dart # UseCase<Success, Params> + NoParams
β
βββ features/
β βββ splash/ # Animated logo, routes via onboarding flag
β βββ onboarding/ # PageView + Get Started
β β
β βββ home/
β β βββ data / domain /
β β βββ presentation/
β β βββ bloc/ # home_{event,state,bloc}.dart β parallel dispatch
β β βββ screens/ # home_screen, movies_list_screen
β β βββ widgets/ # home_hero_section, movie_card, movie_section,
β β # movie_list_item, new_releases_grid (+ NewReleasesShimmer)
β β
β βββ detail/
β β βββ data / domain /
β β βββ presentation/
β β βββ bloc/ # detail_{event,state,bloc}.dart
β β βββ screens/ # detail_screen
β β βββ widgets/ # backdrop_header, poster_title, stats_row, actions,
β β # overview, genres, cast_section, cast_avatar,
β β # similar_section
β β
β βββ search/
β β βββ data / domain /
β β βββ presentation/
β β βββ bloc/ # search_{event,state,bloc}.dart β debounce + switchMap
β β βββ screens/ # search_screen β AnimatedSwitcher across 3 modes
β β βββ widgets/ # search_app_bar, active_filter_chip,
β β # genre_grid, genre_card,
β β # search_results_list, search_result_item,
β β # search_empty_state, search_error_state
β β
β βββ watchlist/
β βββ data/watchlist_storage.dart # Hive wrapper
β βββ presentation/
β βββ bloc/ # watchlist_{event,state,bloc}.dart
β βββ screens/ # watchlist_screen β staggered grid
β βββ widgets/ # watchlist_poster_card (Hero-tagged),
β # watchlist_empty, watchlist_toggle_button
β
βββ shared/
β βββ widgets/ # Cross-feature widgets
β βββ popcorn_button.dart # Gradient / outlined pill CTA
β βββ popcorn_shimmer.dart # Shimmer wrapper w/ default surface fill
β βββ poster_fallback.dart # Missing-poster placeholder (icon + bg)
β βββ glass_back_button.dart # Blurred 48dp round back button
β βββ pressable_scale.dart # Tap-to-shrink + haptic wrapper
β βββ movie_rating.dart # Star + voteAverage row
β βββ app_empty_state.dart # Image + title + subtitle (full screen)
β βββ app_error_state.dart # Icon/image + title + retry (PopcornButton)
β
βββ main.dart # Bootstrap: dotenv β Hive β DI β runApp
- Flutter 3.10+ (Dart 3.x)
- A free TMDB v4 read-access token β grab one here or follow the walkthrough on Medium if it's your first time
git clone https://github.com/<you>/popcorn.git
cd popcorn
# 1. Create your env file
cp .env.example .env
# 2. Paste your TMDB token into .env
# TMDB_ACCESS_TOKEN=eyJhbGciOi...
# 3. Install dependencies
flutter pub get
# 4. Run
flutter runThe project uses Fraunces and Inter, bundled locally. They live at:
assets/fonts/fraunces/Fraunces-{Regular,SemiBold,Bold}.ttf
assets/fonts/inter/Inter-{Regular,Medium,SemiBold,Bold}.ttf
Files are already in the repo; no download needed.
.env is gitignored β never commit your token. .env.example is the template others clone and fill in.
Conventions are defaults, not dogma β but they hold across the codebase and make new slices easy to predict.
- Screens, not Pages. Mobile idiom:
home_screen.dartβclass HomeScreen, lives inscreens/folder. feature/data/+feature/domain/+feature/presentation/slices for anything with real rules. Splash, Onboarding, and core/storage are simpler β single classes.
- Sealed classes for events.
sealed class HomeEvent,SearchEvent,DetailEvent,WatchlistEventβ subclasses arefinal class, compiler enforces exhaustive handling in the bloc. final classfor states. Single state per feature,Equatable, immutable viacopyWith. Not sealed β we use status enum fields instead of state-per-status classes (see below).- Sealed
Either<L, R>inlib/core/types/either.dartwithLeft/Rightsubclasses and afold<T>()method. Nofpdart. - Switch expressions for status-driven UI:
switch (state.status) { MovieStatus.loading => β¦ }β no nestedif/elseladders. - Wildcard
_parameters for unused callback args (e.g.placeholder: (_, _) => β¦).
Dart's guidance is "avoid classes with only static members" β prefer top-level declarations. We follow that by default:
- Top-level
constfor plain strings that read fine unprefixed:tmdbBaseUrl,posterMedium,backdropLarge,watchlistBox,settingsBox,genreBackdropsBox, β¦ β all inlib/core/constants/app_constants.dart, zero classes. abstract final classonly where the group is the semantic unit and the prefix improves readability:AppColors.primaryRose,AppTextStyles.headlineMedium,AppTheme.darkTheme,ApiEndpoints.movieDetail(id),MovieGenres.all. A bareprimaryRoseortrendingat top level would be noise; the class name is the namespace.
Rule of thumb: if you catch yourself importing three related constants together, consider an abstract final class; if each stands on its own, keep it top-level.
- One
Stateclass per feature, with status enums per section.HomeStatehastrendingStatus,popularStatus,topRatedStatus,genresStatusβ each section loads independently, failures don't cascade. Same pattern inDetailState(movieStatus / castStatus / similarStatus) andSearchState(resultsStatus / backdropsStatus). MovieStatusenum is shared, defined once inhome_state.dartand re-exported (export 'home_state.dart' show MovieStatus;) from every other state file. One enum, three names would be worse.copyWithis the only mutation path. Events β bloc handler βemit(state.copyWith(...)). Never mutate collections in place.
Either<Failure, T>at the repository boundary. Data sources throw; repositories funnel calls through the sharedguard<T>()inlib/core/error/guard.dartthat catches each*Exceptionand maps to the matching*Failure. Presentation never sees exceptions.- Datasources return models; repositories return entities.
MovieModel extends Movie+fromJson; UI code touchesMovieonly.
- No hardcoded colors in widgets. Everything goes through
AppColors.*. Genre tiles used to ship their own gradient stops inMovieGenres; we removed them once backdrops replaced the flat design. - Fraunces for display, Inter for body. Never both in one heading.
- 44Γ44 minimum tap targets on anything the user has to hit β back buttons, read-more links, remove chips, genre cards. Visual size can be smaller; hit target is not.
- Architectural scaffolding (DI, router, theme, error types, typography)
- Splash + onboarding flow with Hive-backed first-run flag
- Home discovery (hero carousel + sections + new releases grid + auto-advance + radial glow)
- "See all" β Movies List screen with loading/error/empty branching
- Watchlist with local persistence, shared-element Hero into detail, staggered entry
- Detail screen β backdrop, overlapping hero poster, stats, overview, genres, cast, similar
- Search β 3-mode flow (idle/typing/genre-filtered), backdrop-powered genre grid with Hive-cached deduped fetch, 400ms debounced text search
- Pagination for Movies List (infinite scroll beyond page 1)
- Tests β Bloc unit tests with
bloc_test+mocktail - CI β GitHub Actions (
flutter analyze+ tests on PR)
- Movie data & images courtesy of The Movie Database (TMDB). This product uses the TMDB API but is not endorsed or certified by TMDB.
- Fonts: Fraunces and Inter, SIL Open Font License.
- Popcorn character illustrations β custom assets.










