i want to buld a unified movie discovery and socia...
생성일: 2026년 1월 28일
답변에 사용된 모델 GPT-5.2 Thinking by Chat01
생성일: 2026년 1월 28일
답변에 사용된 모델 GPT-5.2 Thinking by Chat01
i want to buld a unified movie discovery and social review platform for my final year project help me to build it
Absolutely — here’s a solid, buildable plan for a unified movie discovery + social review platform that’s realistic for a final-year project, but still impressive.
A web app (and optionally mobile) where users can:
Think “Letterboxd + IMDb discovery + social feed” in one product.
If you build these well, you have a complete project:
That’s an MVP that looks like a real product.
Frontend: React + Tailwind
Backend: Node.js (Express or NestJS)
DB: PostgreSQL (recommended)
Auth: JWT + refresh tokens (or session cookies)
Movie data: TMDB API (The Movie Database)
Frontend: Next.js (React)
Backend: Next.js API routes (or separate Node API)
DB: PostgreSQL
ORM: Prisma
This is a popular “ship fast” stack.
Backend: Django + Django REST Framework
Frontend: React
DB: PostgreSQL
If you want my suggestion: Next.js + PostgreSQL + Prisma is perfect for a final-year “full product” feel.
Client (React/Next.js)
↕ REST or GraphQL
Backend API (Node/Django)
↕
PostgreSQL DB
↕
External Movie API (TMDB)
Key idea: you don’t store every movie in your DB.
You store:
tmdbId.Here’s a good baseline:
POST /auth/registerPOST /auth/loginPOST /auth/logoutGET /meGET /movies/trendingGET /movies/search?q=...GET /movies/:tmdbIdPOST /movies/:tmdbId/reviewsGET /movies/:tmdbId/reviewsGET /users/:userId/reviewsPATCH /reviews/:reviewIdDELETE /reviews/:reviewIdPOST /follow/:userIdDELETE /follow/:userIdGET /feed (friends’ latest activity)POST /watchlistGET /watchlist?status=WANTDELETE /watchlist/:entryIdYou can add a simple recommender without ML complexity:
If you want, I can give you a fully working algorithm + SQL query approach.
To make your supervisor happy, include:
Tell me these 3 things and I’ll generate a full project blueprint + folder structure + database schema + API routes tailored to you:
If you don’t care, I’ll choose the fastest path and give you a complete starter structure.
mobile app using flutter
Nice — Flutter is a great fit for this. Here’s a practical build plan for a unified movie discovery + social review app that you can actually finish for a final-year project, and still looks “real product” level.
Why this is ideal for a final year project: you avoid writing/hosting a full backend, but you still get a real DB, auth, security rules, and realtime.
TMDB requires you to show a notice like:
“This product uses the TMDB API but is not endorsed or certified by TMDB.”
…and put attribution in an “About/Credits” type section. (The Movie Database (TMDB))
Also be mindful of rate limiting/upper limits (don’t spam requests; cache results). (The Movie Database (TMDB))
Create these tables in Supabase SQL editor:
sql-- Profiles (1:1 with auth.users) create table public.profiles ( id uuid primary key references auth.users(id) on delete cascade, username text unique not null, avatar_url text, bio text, created_at timestamptz default now() ); -- Follows create table public.follows ( follower_id uuid references public.profiles(id) on delete cascade, following_id uuid references public.profiles(id) on delete cascade, created_at timestamptz default now(), primary key (follower_id, following_id), check (follower_id <> following_id) ); -- Reviews create table public.reviews ( id bigserial primary key, user_id uuid references public.profiles(id) on delete cascade, tmdb_movie_id int not null, rating numeric(2,1) not null check (rating >= 0 and rating <= 10), title text, body text not null, has_spoilers boolean default false, created_at timestamptz default now(), updated_at timestamptz default now(), unique (user_id, tmdb_movie_id) ); -- Review likes create table public.review_likes ( user_id uuid references public.profiles(id) on delete cascade, review_id bigint references public.reviews(id) on delete cascade, created_at timestamptz default now(), primary key (user_id, review_id) ); -- Comments (optional but nice) create table public.review_comments ( id bigserial primary key, review_id bigint references public.reviews(id) on delete cascade, user_id uuid references public.profiles(id) on delete cascade, body text not null, created_at timestamptz default now() ); -- Watchlist entries create type public.watch_status as enum ('want', 'watched', 'favorite'); create table public.watchlist ( user_id uuid references public.profiles(id) on delete cascade, tmdb_movie_id int not null, status public.watch_status not null, created_at timestamptz default now(), primary key (user_id, tmdb_movie_id, status) ); -- Activity feed events (for a clean feed) create type public.activity_type as enum ('reviewed', 'liked_review', 'watchlist_added'); create table public.activity ( id bigserial primary key, user_id uuid references public.profiles(id) on delete cascade, type public.activity_type not null, tmdb_movie_id int, review_id bigint references public.reviews(id) on delete set null, created_at timestamptz default now() );
Enable RLS on all tables, then add policies like:
(If you want, I’ll write the exact RLS policy SQL for all tables — it’s a bit long but straightforward.)
textlib/ core/ (theme, constants, utils) data/ (tmdb client, supabase repositories) features/ auth/ discovery/ movie_details/ reviews/ profile/ feed/ watchlist/ router/ main.dart
Use TMDB v4 “API Read Access Token” as:
Authorization: Bearer <token> (The Movie Database (TMDB))
TMDB images require a base_url + size + file_path; you can get base config from /configuration. (The Movie Database (TMDB))
dartclass TmdbClient { TmdbClient(this._dio); final Dio _dio; Future<Response> trendingMovies() { return _dio.get('/trending/movie/day'); } Future<Response> searchMovies(String q) { return _dio.get('/search/movie', queryParameters: {'query': q}); } Future<Response> movieDetails(int id) { return _dio.get('/movie/$id', queryParameters: {'append_to_response': 'credits,videos'}); } }
dartfinal dio = Dio(BaseOptions(baseUrl: 'https://api.themoviedb.org/3')) ..interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { options.headers['Authorization'] = 'Bearer ${const String.fromEnvironment("TMDB_TOKEN")}'; options.headers['accept'] = 'application/json'; return handler.next(options); }, ), );
Tip: If you want to avoid shipping the TMDB token in the app, proxy TMDB via a Supabase Edge Function and call that from Flutter (cleaner for demos, and Supabase supports Edge Functions via its platform + Flutter SDK docs mention them). (Supabase)
supabase.authreviewsactivityExample “create review” flow:
dartFuture<void> createReview({ required int tmdbMovieId, required double rating, required String body, bool spoilers = false, }) async { final user = supabase.auth.currentUser!; await supabase.from('reviews').insert({ 'user_id': user.id, 'tmdb_movie_id': tmdbMovieId, 'rating': rating, 'body': body, 'has_spoilers': spoilers, }); await supabase.from('activity').insert({ 'user_id': user.id, 'type': 'reviewed', 'tmdb_movie_id': tmdbMovieId, }); }
This looks smart in a demo and is quick.
Tell me which one you want first, and whether your app is Android only or Android + iOS.
Generate full RLS policy SQL for all tables (secure + correct)
Below is a complete Supabase RLS script for your tables:
profilesfollowsreviewsreview_likesreview_commentswatchlistactivityIt’s secure by default:
activity) is readable only by you + people who follow you (followers-only feed)Run this after creating the tables.
sql-- ========================================= -- GRANTS (needed in addition to RLS policies) -- ========================================= grant usage on schema public to anon, authenticated; -- Public-readable tables grant select on public.profiles to anon, authenticated; grant select on public.follows to anon, authenticated; grant select on public.reviews to anon, authenticated; grant select on public.review_likes to anon, authenticated; grant select on public.review_comments to anon, authenticated; -- Authenticated-only readable tables (RLS further restricts rows) grant select on public.watchlist to authenticated; grant select on public.activity to authenticated; -- Write permissions (RLS will enforce ownership) grant insert, update, delete on public.profiles to authenticated; grant insert, update, delete on public.follows to authenticated; grant insert, update, delete on public.reviews to authenticated; grant insert, update, delete on public.review_likes to authenticated; grant insert, update, delete on public.review_comments to authenticated; grant insert, update, delete on public.watchlist to authenticated; grant insert, update, delete on public.activity to authenticated; -- Sequences for bigserial IDs (reviews, review_comments, activity) grant usage, select on all sequences in schema public to authenticated; -- ========================================= -- ENABLE RLS -- ========================================= alter table public.profiles enable row level security; alter table public.follows enable row level security; alter table public.reviews enable row level security; alter table public.review_likes enable row level security; alter table public.review_comments enable row level security; alter table public.watchlist enable row level security; alter table public.activity enable row level security; -- ========================================= -- PROFILES -- ========================================= drop policy if exists "profiles_select_public" on public.profiles; create policy "profiles_select_public" on public.profiles for select using (true); drop policy if exists "profiles_insert_own" on public.profiles; create policy "profiles_insert_own" on public.profiles for insert to authenticated with check (auth.uid() = id); drop policy if exists "profiles_update_own" on public.profiles; create policy "profiles_update_own" on public.profiles for update to authenticated using (auth.uid() = id) with check (auth.uid() = id); drop policy if exists "profiles_delete_own" on public.profiles; create policy "profiles_delete_own" on public.profiles for delete to authenticated using (auth.uid() = id); -- ========================================= -- FOLLOWS -- ========================================= drop policy if exists "follows_select_public" on public.follows; create policy "follows_select_public" on public.follows for select using (true); drop policy if exists "follows_insert_self" on public.follows; create policy "follows_insert_self" on public.follows for insert to authenticated with check (auth.uid() = follower_id); drop policy if exists "follows_delete_self" on public.follows; create policy "follows_delete_self" on public.follows for delete to authenticated using (auth.uid() = follower_id); -- (Optional) prevent updates entirely by not creating an UPDATE policy -- ========================================= -- REVIEWS -- ========================================= drop policy if exists "reviews_select_public" on public.reviews; create policy "reviews_select_public" on public.reviews for select using (true); drop policy if exists "reviews_insert_own" on public.reviews; create policy "reviews_insert_own" on public.reviews for insert to authenticated with check (auth.uid() = user_id); drop policy if exists "reviews_update_own" on public.reviews; create policy "reviews_update_own" on public.reviews for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "reviews_delete_own" on public.reviews; create policy "reviews_delete_own" on public.reviews for delete to authenticated using (auth.uid() = user_id); -- ========================================= -- REVIEW LIKES -- ========================================= drop policy if exists "review_likes_select_public" on public.review_likes; create policy "review_likes_select_public" on public.review_likes for select using (true); drop policy if exists "review_likes_insert_own" on public.review_likes; create policy "review_likes_insert_own" on public.review_likes for insert to authenticated with check (auth.uid() = user_id); drop policy if exists "review_likes_delete_own" on public.review_likes; create policy "review_likes_delete_own" on public.review_likes for delete to authenticated using (auth.uid() = user_id); -- ========================================= -- REVIEW COMMENTS -- ========================================= drop policy if exists "review_comments_select_public" on public.review_comments; create policy "review_comments_select_public" on public.review_comments for select using (true); drop policy if exists "review_comments_insert_own" on public.review_comments; create policy "review_comments_insert_own" on public.review_comments for insert to authenticated with check (auth.uid() = user_id); drop policy if exists "review_comments_update_own" on public.review_comments; create policy "review_comments_update_own" on public.review_comments for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "review_comments_delete_own" on public.review_comments; create policy "review_comments_delete_own" on public.review_comments for delete to authenticated using (auth.uid() = user_id); -- ========================================= -- WATCHLIST (private by default: only owner can read) -- ========================================= drop policy if exists "watchlist_select_own" on public.watchlist; create policy "watchlist_select_own" on public.watchlist for select to authenticated using (auth.uid() = user_id); drop policy if exists "watchlist_insert_own" on public.watchlist; create policy "watchlist_insert_own" on public.watchlist for insert to authenticated with check (auth.uid() = user_id); drop policy if exists "watchlist_update_own" on public.watchlist; create policy "watchlist_update_own" on public.watchlist for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); drop policy if exists "watchlist_delete_own" on public.watchlist; create policy "watchlist_delete_own" on public.watchlist for delete to authenticated using (auth.uid() = user_id); -- ========================================= -- ACTIVITY FEED (followers-only + self) -- People can read: -- - their own activity -- - activity of users they follow -- ========================================= drop policy if exists "activity_select_following" on public.activity; create policy "activity_select_following" on public.activity for select to authenticated using ( auth.uid() = user_id OR exists ( select 1 from public.follows f where f.follower_id = auth.uid() and f.following_id = user_id ) ); drop policy if exists "activity_insert_own" on public.activity; create policy "activity_insert_own" on public.activity for insert to authenticated with check (auth.uid() = user_id); drop policy if exists "activity_delete_own" on public.activity; create policy "activity_delete_own" on public.activity for delete to authenticated using (auth.uid() = user_id); -- (Optional) prevent updates entirely by not creating an UPDATE policy
If you tell me whether you want watchlists public on profiles (common for movie apps), I’ll modify the watchlist policies to support:
Generate a ready-to-run Flutter starter structure (Riverpod + routing + TMDB client + Supabase auth)
Generate a ready-to-run Flutter starter structure (Riverpod + routing + TMDB client + Supabase auth)
Below is a ready-to-run Flutter starter with:
flutter_riverpod ^3.2.0) (Dart packages)go_router ^17.0.1) (Dart packages)dio ^5.9.1) (Dart packages)supabase_flutter ^2.12.0) (Dart packages)It includes: Login, Register, Discover (Trending), Search, Movie Details, Profile + Sign out.
bashflutter create movie_social cd movie_social
Uses the latest versions shown on pub.dev above. (Dart packages)
yamldependencies: flutter: sdk: flutter flutter_riverpod: ^3.2.0 go_router: ^17.0.1 dio: ^5.9.1 supabase_flutter: ^2.12.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0
Then:
bashflutter pub get
You’ll run the app like this (replace values):
bashflutter run \ --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \ --dart-define=SUPABASE_ANON_KEY=YOUR_ANON_KEY \ --dart-define=TMDB_TOKEN=YOUR_TMDB_V4_READ_TOKEN
textlib/ main.dart router/app_router.dart core/app_env.dart core/widgets/async_value_view.dart data/supabase/supabase_providers.dart data/tmdb/tmdb_client.dart data/tmdb/tmdb_models.dart data/tmdb/tmdb_providers.dart features/auth/login_page.dart features/auth/register_page.dart features/shell/app_shell.dart features/discovery/discover_page.dart features/discovery/search_page.dart features/movie/movie_details_page.dart features/profile/profile_page.dart
Now paste the code files below.
dartclass AppEnv { static const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); static const supabaseAnonKey = String.fromEnvironment('SUPABASE_ANON_KEY'); static const tmdbToken = String.fromEnvironment('TMDB_TOKEN'); static void validate() { final missing = <String>[]; if (supabaseUrl.isEmpty) missing.add('SUPABASE_URL'); if (supabaseAnonKey.isEmpty) missing.add('SUPABASE_ANON_KEY'); if (tmdbToken.isEmpty) missing.add('TMDB_TOKEN'); if (missing.isNotEmpty) { throw StateError( 'Missing dart-defines: ${missing.join(', ')}.\n' 'Run: flutter run --dart-define=SUPABASE_URL=... --dart-define=SUPABASE_ANON_KEY=... --dart-define=TMDB_TOKEN=...', ); } } }
dartimport 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; final supabaseClientProvider = Provider<SupabaseClient>((ref) { return Supabase.instance.client; }); final authStateProvider = StreamProvider<AuthState>((ref) { final client = ref.watch(supabaseClientProvider); return client.auth.onAuthStateChange; }); final sessionProvider = Provider<Session?>((ref) { final client = ref.watch(supabaseClientProvider); return client.auth.currentSession; });
dartimport 'package:dio/dio.dart'; class TmdbClient { TmdbClient({ required String token, Dio? dio, }) : _dio = dio ?? Dio(BaseOptions(baseUrl: 'https://api.themoviedb.org/3')) { _dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { options.headers['Authorization'] = 'Bearer $token'; options.headers['accept'] = 'application/json'; handler.next(options); }, ), ); } final Dio _dio; Future<Map<String, dynamic>> trendingMovies() async { final res = await _dio.get('/trending/movie/day'); return (res.data as Map).cast<String, dynamic>(); } Future<Map<String, dynamic>> searchMovies(String query) async { final res = await _dio.get( '/search/movie', queryParameters: {'query': query}, ); return (res.data as Map).cast<String, dynamic>(); } Future<Map<String, dynamic>> movieDetails(int id) async { final res = await _dio.get( '/movie/$id', queryParameters: {'append_to_response': 'credits,videos'}, ); return (res.data as Map).cast<String, dynamic>(); } }
dartclass TmdbMovie { final int id; final String title; final String? posterPath; final String? overview; final String? releaseDate; final double? voteAverage; TmdbMovie({ required this.id, required this.title, required this.posterPath, required this.overview, required this.releaseDate, required this.voteAverage, }); factory TmdbMovie.fromJson(Map<String, dynamic> json) { return TmdbMovie( id: json['id'] as int, title: (json['title'] ?? json['name'] ?? '') as String, posterPath: json['poster_path'] as String?, overview: json['overview'] as String?, releaseDate: json['release_date'] as String?, voteAverage: (json['vote_average'] as num?)?.toDouble(), ); } } class CastMember { final String name; final String? character; CastMember({required this.name, required this.character}); factory CastMember.fromJson(Map<String, dynamic> json) { return CastMember( name: (json['name'] ?? '') as String, character: json['character'] as String?, ); } } class MovieDetails { final TmdbMovie movie; final List<CastMember> castTop; MovieDetails({required this.movie, required this.castTop}); factory MovieDetails.fromJson(Map<String, dynamic> json) { final movie = TmdbMovie.fromJson(json); final credits = (json['credits'] as Map?)?.cast<String, dynamic>(); final cast = (credits?['cast'] as List?) ?.map((e) => CastMember.fromJson((e as Map).cast<String, dynamic>())) .toList() ?? const <CastMember>[]; return MovieDetails( movie: movie, castTop: cast.take(10).toList(), ); } } /// Simple helper for TMDB posters (works without /configuration). class TmdbImages { static String? posterW500(String? posterPath) { if (posterPath == null || posterPath.isEmpty) return null; return 'https://image.tmdb.org/t/p/w500$posterPath'; } }
dartimport 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_env.dart'; import 'tmdb_client.dart'; import 'tmdb_models.dart'; final tmdbClientProvider = Provider<TmdbClient>((ref) { return TmdbClient(token: AppEnv.tmdbToken); }); final trendingMoviesProvider = FutureProvider<List<TmdbMovie>>((ref) async { final api = ref.watch(tmdbClientProvider); final data = await api.trendingMovies(); final results = (data['results'] as List).cast<dynamic>(); return results .map((e) => TmdbMovie.fromJson((e as Map).cast<String, dynamic>())) .toList(); }); final searchMoviesProvider = FutureProvider.family<List<TmdbMovie>, String>((ref, query) async { if (query.trim().isEmpty) return const <TmdbMovie>[]; final api = ref.watch(tmdbClientProvider); final data = await api.searchMovies(query.trim()); final results = (data['results'] as List).cast<dynamic>(); return results .map((e) => TmdbMovie.fromJson((e as Map).cast<String, dynamic>())) .toList(); }); final movieDetailsProvider = FutureProvider.family<MovieDetails, int>((ref, id) async { final api = ref.watch(tmdbClientProvider); final data = await api.movieDetails(id); return MovieDetails.fromJson(data); });
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class AsyncValueView<T> extends StatelessWidget { const AsyncValueView({ super.key, required this.value, required this.data, }); final AsyncValue<T> value; final Widget Function(T) data; Widget build(BuildContext context) { return value.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, st) => Center( child: Padding( padding: const EdgeInsets.all(16), child: Text('Error: $e'), ), ), data: data, ); } }
dartimport 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../features/auth/login_page.dart'; import '../features/auth/register_page.dart'; import '../features/discovery/discover_page.dart'; import '../features/movie/movie_details_page.dart'; import '../features/profile/profile_page.dart'; import '../features/shell/app_shell.dart'; class GoRouterRefreshStream extends ChangeNotifier { GoRouterRefreshStream(Stream<dynamic> stream) { _sub = stream.asBroadcastStream().listen((_) => notifyListeners()); } late final StreamSubscription<dynamic> _sub; void dispose() { _sub.cancel(); super.dispose(); } } GoRouter createRouter(SupabaseClient supabase) { final refresh = GoRouterRefreshStream(supabase.auth.onAuthStateChange); return GoRouter( initialLocation: '/discover', refreshListenable: refresh, redirect: (context, state) { final loggedIn = supabase.auth.currentSession != null; final isAuthRoute = state.matchedLocation == '/login' || state.matchedLocation == '/register'; if (!loggedIn && !isAuthRoute) return '/login'; if (loggedIn && isAuthRoute) return '/discover'; return null; }, routes: [ GoRoute( path: '/login', builder: (context, state) => const LoginPage(), ), GoRoute( path: '/register', builder: (context, state) => const RegisterPage(), ), ShellRoute( builder: (context, state, child) => AppShell(child: child), routes: [ GoRoute( path: '/discover', builder: (context, state) => const DiscoverPage(), ), GoRoute( path: '/profile', builder: (context, state) => const ProfilePage(), ), ], ), GoRoute( path: '/movie/:id', builder: (context, state) { final id = int.parse(state.pathParameters['id']!); return MovieDetailsPage(movieId: id); }, ), ], ); }
dartimport 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class AppShell extends StatelessWidget { const AppShell({super.key, required this.child}); final Widget child; int _indexFromLocation(String location) { if (location.startsWith('/profile')) return 1; return 0; // discover default } Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; final currentIndex = _indexFromLocation(location); return Scaffold( body: child, bottomNavigationBar: NavigationBar( selectedIndex: currentIndex, destinations: const [ NavigationDestination( icon: Icon(Icons.local_movies_outlined), selectedIcon: Icon(Icons.local_movies), label: 'Discover', ), NavigationDestination( icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile', ), ], onDestinationSelected: (i) { if (i == 0) context.go('/discover'); if (i == 1) context.go('/profile'); }, ), ); } }
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/supabase/supabase_providers.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); ConsumerState<LoginPage> createState() => _LoginPageState(); } class _LoginPageState extends ConsumerState<LoginPage> { final _email = TextEditingController(); final _password = TextEditingController(); bool _busy = false; void dispose() { _email.dispose(); _password.dispose(); super.dispose(); } Future<void> _login() async { setState(() => _busy = true); try { final supabase = ref.read(supabaseClientProvider); await supabase.auth.signInWithPassword( email: _email.text.trim(), password: _password.text, ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); } finally { if (mounted) setState(() => _busy = false); } } Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Login')), body: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TextField( controller: _email, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration(labelText: 'Email'), ), const SizedBox(height: 12), TextField( controller: _password, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), ), const SizedBox(height: 16), FilledButton( onPressed: _busy ? null : _login, child: Text(_busy ? 'Signing in…' : 'Sign in'), ), const SizedBox(height: 12), TextButton( onPressed: _busy ? null : () => Navigator.of(context).pushNamed('/register'), child: const Text('Create an account'), ), ], ), ), ); } }
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/supabase/supabase_providers.dart'; class RegisterPage extends ConsumerStatefulWidget { const RegisterPage({super.key}); ConsumerState<RegisterPage> createState() => _RegisterPageState(); } class _RegisterPageState extends ConsumerState<RegisterPage> { final _email = TextEditingController(); final _password = TextEditingController(); bool _busy = false; void dispose() { _email.dispose(); _password.dispose(); super.dispose(); } Future<void> _register() async { setState(() => _busy = true); try { final supabase = ref.read(supabaseClientProvider); await supabase.auth.signUp( email: _email.text.trim(), password: _password.text, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Account created. You can now log in.')), ); Navigator.of(context).pop(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e'))); } finally { if (mounted) setState(() => _busy = false); } } Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Register')), body: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ TextField( controller: _email, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration(labelText: 'Email'), ), const SizedBox(height: 12), TextField( controller: _password, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), ), const SizedBox(height: 16), FilledButton( onPressed: _busy ? null : _register, child: Text(_busy ? 'Creating…' : 'Create account'), ), ], ), ), ); } }
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/widgets/async_value_view.dart'; import '../../data/tmdb/tmdb_models.dart'; import '../../data/tmdb/tmdb_providers.dart'; import 'search_page.dart'; class DiscoverPage extends ConsumerWidget { const DiscoverPage({super.key}); Widget build(BuildContext context, WidgetRef ref) { final trending = ref.watch(trendingMoviesProvider); return Scaffold( appBar: AppBar( title: const Text('Discover'), actions: [ IconButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SearchPage()), ), icon: const Icon(Icons.search), ), ], ), body: AsyncValueView<List<TmdbMovie>>( value: trending, data: (movies) => ListView.separated( itemCount: movies.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, i) { final m = movies[i]; return ListTile( leading: const Icon(Icons.movie), title: Text(m.title), subtitle: Text(m.releaseDate ?? '—'), trailing: Text(m.voteAverage?.toStringAsFixed(1) ?? ''), onTap: () => context.push('/movie/${m.id}'), ); }, ), ), ); } }
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/widgets/async_value_view.dart'; import '../../data/tmdb/tmdb_models.dart'; import '../../data/tmdb/tmdb_providers.dart'; final _queryProvider = StateProvider<String>((ref) => ''); class SearchPage extends ConsumerWidget { const SearchPage({super.key}); Widget build(BuildContext context, WidgetRef ref) { final query = ref.watch(_queryProvider); final results = ref.watch(searchMoviesProvider(query)); return Scaffold( appBar: AppBar(title: const Text('Search')), body: Column( children: [ Padding( padding: const EdgeInsets.all(12), child: TextField( autofocus: true, decoration: const InputDecoration( hintText: 'Search movies…', prefixIcon: Icon(Icons.search), ), onChanged: (v) => ref.read(_queryProvider.notifier).state = v, ), ), Expanded( child: AsyncValueView<List<TmdbMovie>>( value: results, data: (movies) { if (query.trim().isEmpty) { return const Center(child: Text('Type to search.')); } if (movies.isEmpty) { return const Center(child: Text('No results.')); } return ListView.separated( itemCount: movies.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, i) { final m = movies[i]; return ListTile( title: Text(m.title), subtitle: Text(m.releaseDate ?? '—'), onTap: () => context.push('/movie/${m.id}'), ); }, ); }, ), ), ], ), ); } }
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/widgets/async_value_view.dart'; import '../../data/tmdb/tmdb_models.dart'; import '../../data/tmdb/tmdb_providers.dart'; class MovieDetailsPage extends ConsumerWidget { const MovieDetailsPage({super.key, required this.movieId}); final int movieId; Widget build(BuildContext context, WidgetRef ref) { final details = ref.watch(movieDetailsProvider(movieId)); return Scaffold( appBar: AppBar(title: const Text('Movie')), body: AsyncValueView<MovieDetails>( value: details, data: (d) { final m = d.movie; final posterUrl = TmdbImages.posterW500(m.posterPath); return ListView( padding: const EdgeInsets.all(16), children: [ if (posterUrl != null) ClipRRect( borderRadius: BorderRadius.circular(12), child: Image.network(posterUrl, height: 280, fit: BoxFit.cover), ), const SizedBox(height: 12), Text( m.title, style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 6), Text('${m.releaseDate ?? '—'} • ⭐ ${m.voteAverage?.toStringAsFixed(1) ?? '—'}'), const SizedBox(height: 12), Text(m.overview ?? 'No overview.'), const SizedBox(height: 16), Text('Top Cast', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), ...d.castTop.map((c) => Text('• ${c.name}${c.character != null ? ' as ${c.character}' : ''}')), const SizedBox(height: 24), const Divider(), const SizedBox(height: 8), const Text( 'Reviews coming next: connect this screen to your Supabase reviews table.', ), ], ); }, ), ); } }
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/supabase/supabase_providers.dart'; class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); Widget build(BuildContext context, WidgetRef ref) { final supabase = ref.watch(supabaseClientProvider); final user = supabase.auth.currentUser; return Scaffold( appBar: AppBar(title: const Text('Profile')), body: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('User: ${user?.email ?? '—'}'), const SizedBox(height: 12), FilledButton( onPressed: () async { await supabase.auth.signOut(); }, child: const Text('Sign out'), ), const SizedBox(height: 24), const Text( 'TMDB Attribution: Add an About/Credits screen:\n' '"This product uses the TMDB API but is not endorsed or certified by TMDB."', ), ], ), ), ); } }
dartimport 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'core/app_env.dart'; import 'data/supabase/supabase_providers.dart'; import 'router/app_router.dart'; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); AppEnv.validate(); await Supabase.initialize( url: AppEnv.supabaseUrl, anonKey: AppEnv.supabaseAnonKey, ); runApp(const ProviderScope(child: MyApp())); } class MyApp extends ConsumerWidget { const MyApp({super.key}); Widget build(BuildContext context, WidgetRef ref) { final supabase = ref.watch(supabaseClientProvider); final router = createRouter(supabase); return MaterialApp.router( title: 'Movie Social', theme: ThemeData(useMaterial3: true), routerConfig: router, ); } }
In LoginPage, I used Navigator.pushNamed('/register')—but we’re using go_router. Replace that button with:
dartTextButton( onPressed: _busy ? null : () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const RegisterPage()), ), child: const Text('Create an account'), ),
(or switch it to context.go('/register') if you import go_router).
If you want, I’ll generate the next slice (fully wired to your Supabase tables + RLS you already added):
Say: “Add reviews next” and I’ll drop the exact tables-to-UI code (providers + queries + pages).
Design your ERD + architecture diagram + a clean feature list for your report
tmdb_movie_id (you do not store full movie rows in your DB)mermaiderDiagram AUTH_USERS ||--|| PROFILES : "1:1 (id)" PROFILES ||--o{ REVIEWS : writes PROFILES ||--o{ WATCHLIST : saves PROFILES ||--o{ ACTIVITY : generates PROFILES ||--o{ FOLLOWS : follower PROFILES ||--o{ FOLLOWS : following REVIEWS ||--o{ REVIEW_COMMENTS : has PROFILES ||--o{ REVIEW_COMMENTS : posts PROFILES ||--o{ REVIEW_LIKES : likes REVIEWS ||--o{ REVIEW_LIKES : receives %% External reference (conceptual) TMDB_MOVIE ||--o{ REVIEWS : "referenced by tmdb_movie_id" TMDB_MOVIE ||--o{ WATCHLIST : "referenced by tmdb_movie_id" TMDB_MOVIE ||--o{ ACTIVITY : "referenced by tmdb_movie_id" AUTH_USERS { uuid id PK text email } PROFILES { uuid id PK "FK -> auth.users.id" text username "unique" text avatar_url text bio timestamptz created_at } FOLLOWS { uuid follower_id PK "FK -> profiles.id" uuid following_id PK "FK -> profiles.id" timestamptz created_at } REVIEWS { bigint id PK uuid user_id "FK -> profiles.id" int tmdb_movie_id numeric rating text title text body boolean has_spoilers timestamptz created_at timestamptz updated_at } REVIEW_LIKES { uuid user_id PK "FK -> profiles.id" bigint review_id PK "FK -> reviews.id" timestamptz created_at } REVIEW_COMMENTS { bigint id PK bigint review_id "FK -> reviews.id" uuid user_id "FK -> profiles.id" text body timestamptz created_at } WATCHLIST { uuid user_id PK "FK -> profiles.id" int tmdb_movie_id PK watch_status status PK "want|watched|favorite" timestamptz created_at } ACTIVITY { bigint id PK uuid user_id "FK -> profiles.id" activity_type type "reviewed|liked_review|watchlist_added" int tmdb_movie_id bigint review_id "nullable FK -> reviews.id" timestamptz created_at } TMDB_MOVIE { int id PK "external" }
mermaidflowchart LR A[Flutter App\n(UI + Riverpod + Repos)] -->|Discover/Search/Details| B[TMDB API\nMovies + Posters + Cast] A -->|Auth| C[Supabase Auth] A -->|CRUD Social Data| D[Supabase Postgres\nprofiles, reviews, follows, likes,\ncomments, watchlist, activity] A -->|Upload/Download| E[Supabase Storage\nAvatars] D -->|Changes/Subscriptions (optional)| F[Supabase Realtime] F -->|Live updates (optional)| A %% Optional proxy to hide TMDB token + standardize responses A -. optional .-> G[Supabase Edge Functions\nTMDB Proxy / Aggregation] G -. optional .-> B G -. optional .-> D
tmdb_movie_id.Authentication & Accounts
Movie Discovery (TMDB-backed)
Reviews & Ratings (Social Layer)
Watchlist / Tracking
Social Graph
Feed
If you want, I can also generate: