Skip to content

aydozy/popcorn

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

15 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

🍿 Popcorn

Flutter Dart Bloc Clean Architecture Status

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.


πŸ“± App Icon

Popcorn app icon

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


✨ Screenshots

Splash + Onboarding

Splash β€” animated logo with gold/red glow Onboarding slide 1 Onboarding slide 2

Animated splash reads the onboarding flag from Hive and routes accordingly. Returning users skip straight to Home.

Home

Home β€” hero carousel and sections Home β€” New releases grid + nav shell Movies list β€” 'See all' destination

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.

Detail

Detail β€” backdrop, overlapping poster, stats Detail β€” overview, genres, cast, similar

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.

Search

Search β€” genre grid with real movie backdrops Search β€” debounced text results

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 dense SearchResultItem (poster, title, year Β· genre, rating, 2-line overview, inline watchlist toggle).
  • Genre-filtered β€” tap any tile β†’ shows that genre's popular movies with an ActiveFilterChip to clear back to idle.

Empty state uses problem_popcorn.png; network failure renders an inline retry with connection_popcorn.png.

Watchlist

Watchlist β€” 2-col poster grid with staggered entry

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.


Features

  • 🎬 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

Tech Stack

Runtime

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

Dev

build_runner, hive_generator, bloc_test, mocktail

Not used (intentionally)

  • ❌ injectable β€” manual getIt.registerLazySingleton / registerFactory is readable and sufficient
  • ❌ fpdart β€” we ship our own tiny sealed Either<L, R> in lib/core/types/
  • ❌ google_fonts β€” Fraunces + Inter bundled as local .ttf assets for smaller dependency surface
  • ❌ share_plus β€” share button explicitly out of scope for MVP

Typography

  • Fraunces β€” display, headlines, movie titles
  • Inter β€” body text, labels, buttons

Both bundled locally under assets/fonts/.


Architecture

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.

Decision rule

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/

Layer breakdown (Detail as example)

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

State management

  • Sealed events + single state per feature. Each feature has one State class with per-slice status fields (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 via MultiBlocProvider in main.dart, mounted with BlocProvider.value so the singleton isn't closed on widget dispose.
  • Screen-scoped factory Blocs for Detail + Search. Each /movie/:id push creates a fresh DetailBloc; each /search open creates a fresh SearchBloc. Transient state, no leak risk.
  • Event transformers for debounce. SearchBloc uses stream_transform's debounce(400ms).switchMap so a burst of keystrokes collapses to one request, and in-flight stale requests get cancelled.

Error handling

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.

Routing

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" destination

Folder Structure

lib/
β”œβ”€β”€ 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

Getting Started

Prerequisites

Setup

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 run

Font assets

The 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.

Environment

.env is gitignored β€” never commit your token. .env.example is the template others clone and fill in.


Project Conventions

Conventions are defaults, not dogma β€” but they hold across the codebase and make new slices easy to predict.

Naming + structure

  • Screens, not Pages. Mobile idiom: home_screen.dart β†’ class HomeScreen, lives in screens/ folder.
  • feature/data/ + feature/domain/ + feature/presentation/ slices for anything with real rules. Splash, Onboarding, and core/storage are simpler β€” single classes.

Dart 3 features we lean on

  • Sealed classes for events. sealed class HomeEvent, SearchEvent, DetailEvent, WatchlistEvent β€” subclasses are final class, compiler enforces exhaustive handling in the bloc.
  • final class for states. Single state per feature, Equatable, immutable via copyWith. Not sealed β€” we use status enum fields instead of state-per-status classes (see below).
  • Sealed Either<L, R> in lib/core/types/either.dart with Left / Right subclasses and a fold<T>() method. No fpdart.
  • Switch expressions for status-driven UI: switch (state.status) { MovieStatus.loading => … } β€” no nested if/else ladders.
  • Wildcard _ parameters for unused callback args (e.g. placeholder: (_, _) => …).

Top-level first, abstract final class only when namespacing earns its keep

Dart's guidance is "avoid classes with only static members" β€” prefer top-level declarations. We follow that by default:

  • Top-level const for plain strings that read fine unprefixed: tmdbBaseUrl, posterMedium, backdropLarge, watchlistBox, settingsBox, genreBackdropsBox, … β€” all in lib/core/constants/app_constants.dart, zero classes.
  • abstract final class only where the group is the semantic unit and the prefix improves readability: AppColors.primaryRose, AppTextStyles.headlineMedium, AppTheme.darkTheme, ApiEndpoints.movieDetail(id), MovieGenres.all. A bare primaryRose or trending at 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.

State modeling

  • One State class per feature, with status enums per section. HomeState has trendingStatus, popularStatus, topRatedStatus, genresStatus β€” each section loads independently, failures don't cascade. Same pattern in DetailState (movieStatus / castStatus / similarStatus) and SearchState (resultsStatus / backdropsStatus).
  • MovieStatus enum is shared, defined once in home_state.dart and re-exported (export 'home_state.dart' show MovieStatus;) from every other state file. One enum, three names would be worse.
  • copyWith is the only mutation path. Events β†’ bloc handler β†’ emit(state.copyWith(...)). Never mutate collections in place.

Data layer

  • Either<Failure, T> at the repository boundary. Data sources throw; repositories funnel calls through the shared guard<T>() in lib/core/error/guard.dart that catches each *Exception and maps to the matching *Failure. Presentation never sees exceptions.
  • Datasources return models; repositories return entities. MovieModel extends Movie + fromJson; UI code touches Movie only.

Styling

  • No hardcoded colors in widgets. Everything goes through AppColors.*. Genre tiles used to ship their own gradient stops in MovieGenres; we removed them once backdrops replaced the flat design.
  • Fraunces for display, Inter for body. Never both in one heading.

Touch targets

  • 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.

Roadmap

  • 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)

Credits

  • 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.

About

🍿 Discover your next favorite movie. A Flutter app showcasing Clean Architecture, Bloc, and TMDB API integration.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages