From 479d886fc6594b13c95b5f271b72a1a1b1ba5999 Mon Sep 17 00:00:00 2001 From: truongnq Date: Mon, 2 Mar 2026 13:17:24 +0700 Subject: [PATCH 1/8] docs: Expand README with a comprehensive project overview, tech stack, architecture, and setup instructions. --- README.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0499b80..7aae922 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,96 @@ -# flutter_base_project +# Flutter Base Project (Movie App Demo) -A new Flutter project. +A modern, responsive Flutter application showcasing a Clean Architecture implementation with a robust set of libraries and tools. This project serves as a comprehensive base for building scalable Flutter apps, currently demonstrating a Movie Database integration. + +## 🚀 Tech Stack + +- **State Management**: [flutter_bloc](https://pub.dev/packages/flutter_bloc) +- **Routing**: [go_router](https://pub.dev/packages/go_router) +- **Dependency Injection**: [get_it](https://pub.dev/packages/get_it) +- **Networking**: [dio](https://pub.dev/packages/dio) & [retrofit](https://pub.dev/packages/retrofit) +- **JSON Parsing**: [json_serializable](https://pub.dev/packages/json_serializable) & [json_annotation](https://pub.dev/packages/json_annotation) +- **Data Comparison**: [equatable](https://pub.dev/packages/equatable) +- **Image Caching**: [cached_network_image](https://pub.dev/packages/cached_network_image) +- **Local Storage**: [shared_preferences](https://pub.dev/packages/shared_preferences) + +--- + +## 🏗 Project Structure + +The project strictly follows **Clean Architecture** principles, dividing the code into discrete, decoupled layers: + +```text +lib/ +├── core/ +│ ├── navigator/ # GoRouter configuration & routes +│ ├── network/ # Dio client, API Constants, Retrofit ApiClients +│ ├── services/ # Service Locator (GetIt dependency injection config) +│ ├── theme/ # AppTheme, colors, and ThemeCubit (Light/Dark Mode) +│ └── widgets/ # Reusable, completely stateless UI components (AppBars, Buttons, Scaffold) +│ +├── data/ +│ ├── datasources/ # Remote (Retrofit) and Local (SharedPreferences) data sources +│ ├── models/ # Data Models bridging the Domain Entities to JSON structs +│ └── repositories/ # Concrete implementations of Domain Repository interfaces +│ +├── domain/ +│ ├── entities/ # Pure Dart objects representing core domain business data (e.g. Movie) +│ ├── repositories/ # Abstract interfaces mapping out Data demands +│ └── usecases/ # Specific actions the app can perform (e.g. GetPopularMovies) +│ +├── presentation/ # Organized by Feature (e.g., home, detail, onboarding) +│ ├── detail/ # Movie detail screen and its cubits +│ ├── home/ # Home listing screen and its cubits +│ └── onboarding/ # First-time user experience and onboarding bloc +│ +└── main.dart # Application entry point & root BlocProviders +``` + +--- + +## 🛠 Setup & Installation + +**1. Clone the repository and install dependencies** +```bash +git clone +cd flutter_base_project +flutter pub get +``` + +**2. Run Code Generation (Crucial for Retrofit / JSON Serializable)** +Because this project utilizes code generation for API Clients and JSON parsing, you must run `build_runner` before the project can compile successfully. + +```bash +# Run one time to build generated files (.g.dart) +dart run build_runner build --delete-conflicting-outputs + +# OR run securely in the background checking for file changes +dart run build_runner watch --delete-conflicting-outputs +``` + +--- + +## ▶️ Running the App + +You can run the app using the standard Flutter CLI commands: + +```bash +# Run on connected device or simulator +flutter run + +# Run in debug mode explicitly +flutter run --debug + +# Run unit tests (if any) +flutter test + +# Run static analysis to catch syntax or linting errors +flutter analyze +``` + +--- + +## 🌙 Features & Highlights +* **Dynamic Theming**: An interactive toggle inside the Home App Bar seamlessly switches between Light and Dark mode using a `ThemeCubit`. +* **Code-Generated API Routes**: Utilizes Retrofit to cleanly declare API routes on an interface, abstracting away complex parsing and boilerplate code. +* **Component Extraction**: All generic UI logic (Scaffolds, custom App Bars, Back Buttons, Network Images) are perfectly modularized into `lib/core/widgets` for instantaneous reuse. From e39cb2a08416421992531b57c52a063d52b37139 Mon Sep 17 00:00:00 2001 From: truongnq Date: Mon, 2 Mar 2026 13:29:03 +0700 Subject: [PATCH 2/8] feat: configure deep linking for the moviedb scheme on Android and iOS. --- android/app/src/main/AndroidManifest.xml | 9 +++++++++ ios/Runner/Info.plist | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 96d1601..91f6792 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,6 +24,15 @@ + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index eaa1973..4e1fa5c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,5 +45,20 @@ UIApplicationSupportsIndirectInputEvents + FlutterDeepLinkingEnabled + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + flutter_base_project + CFBundleURLSchemes + + moviedb + + + From 2a4ca6cab775e17b47b6b2da7b7461bf8021810d Mon Sep 17 00:00:00 2001 From: Truong Ngo <87740125+CodeAndThink@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:35:19 +0700 Subject: [PATCH 3/8] Update GitHub Actions workflow for Flutter CI --- .github/workflows/dart.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/dart.yml diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 0000000..2939dba --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,31 @@ +name: Flutter CI + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate files + run: dart run build_runner build --delete-conflicting-outputs + + - name: Analyze project + run: flutter analyze + + - name: Run tests + run: flutter test From 72df058fcf432e2c57f830fb591ce0df2040f3c9 Mon Sep 17 00:00:00 2001 From: truongnq Date: Mon, 2 Mar 2026 13:38:16 +0700 Subject: [PATCH 4/8] ci: Pin Flutter version to 3.35.4 and add a verification step in the CI workflow. --- .github/workflows/dart.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 2939dba..d8a522e 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -15,9 +15,13 @@ jobs: - uses: subosito/flutter-action@v2 with: - flutter-version: stable + channel: stable + flutter-version: 3.35.4 cache: true + - name: Verify Flutter version + run: flutter --version + - name: Install dependencies run: flutter pub get From 8ca621da70f71c8aff40f82af82ecacea6c3a7d1 Mon Sep 17 00:00:00 2001 From: truongnq Date: Mon, 2 Mar 2026 13:41:31 +0700 Subject: [PATCH 5/8] ci: Remove 'Run tests' step from Dart workflow. --- .github/workflows/dart.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index d8a522e..7c5d0a0 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -30,6 +30,3 @@ jobs: - name: Analyze project run: flutter analyze - - - name: Run tests - run: flutter test From 2afa5d7fccaba0e4b76dfce30b6c917a25f32e96 Mon Sep 17 00:00:00 2001 From: truongnq Date: Tue, 3 Mar 2026 11:04:35 +0700 Subject: [PATCH 6/8] refactor: migrate UI components from `presentation` to `ui` directories, restructure data models, and update routing and error handling. --- lib/core/common/app_navigator.dart | 36 +++++ lib/core/common/theme/app_colors.dart | 1 + lib/core/{ => common}/theme/theme.dart | 0 lib/core/{ => common}/theme/theme_cubit.dart | 0 lib/core/configs/app_configs.dart | 5 + .../database}/local_storage_datasource.dart | 0 lib/core/failure/app_failure.dart | 89 +++++++++++++ lib/core/network/api_clients.dart | 4 +- lib/core/services/service_locator.dart | 81 ----------- lib/core/theme/app_colors.dart | 0 lib/core/utils/app_utils.dart | 1 + .../datasources/movie_remote_data_source.dart | 37 ----- .../models}/entities/movie.dart | 0 .../models}/entities/onboarding_item.dart | 0 lib/data/models/enum/load_status.dart | 16 +++ .../models/{ => response}/movie_model.dart | 2 +- .../models/{ => response}/movie_model.g.dart | 0 .../{ => response}/movie_response_model.dart | 0 .../movie_response_model.g.dart | 0 lib/data/repositories/movie_repository.dart | 40 ++++++ .../repositories/movie_repository_impl.dart | 29 ---- ...y_impl.dart => onboarding_repository.dart} | 9 +- lib/domain/repositories/movie_repository.dart | 6 - .../repositories/onboarding_repository.dart | 5 - .../usecases/check_onboarding_status.dart | 11 -- .../usecases/clear_onboarding_status.dart | 11 -- lib/domain/usecases/get_movie_details.dart | 12 -- lib/domain/usecases/get_popular_movies.dart | 12 -- .../usecases/save_onboarding_status.dart | 11 -- lib/main.dart | 73 +++++++--- .../detail/cubit/movie_detail_cubit.dart | 20 --- .../detail/cubit/movie_detail_state.dart | 31 ----- lib/presentation/home/cubit/home_cubit.dart | 13 -- .../home/cubit/home_movie_cubit.dart | 19 --- .../home/cubit/home_movie_state.dart | 31 ----- lib/presentation/home/pages/home_page.dart | 85 ------------ .../home/pages/movie_detail_page.dart | 86 ------------ .../navigator => router}/app_router.dart | 26 +++- .../detail/cubit/movie_detail_cubit.dart | 28 ++++ .../detail/cubit/movie_detail_state.dart | 30 +++++ .../navigator/movie_detail_navigator.dart | 5 + .../pages/detail/page/movie_detail_page.dart | 126 ++++++++++++++++++ lib/ui/pages/home/cubit/home_cubit.dart | 43 ++++++ lib/ui/pages/home/cubit/home_state.dart | 30 +++++ .../pages/home/navigator/home_navigator.dart | 12 ++ lib/ui/pages/home/page/home_page.dart | 95 +++++++++++++ lib/ui/pages/home/widgets/home_list_item.dart | 40 ++++++ .../onboarding/bloc/onboarding_bloc.dart | 16 +-- .../onboarding/bloc/onboarding_event.dart | 0 .../onboarding/bloc/onboarding_state.dart | 0 .../onboarding/page}/onboarding_page.dart | 13 +- .../widgets/appbars/custom_app_bar.dart | 0 .../appbars/custom_sliver_app_bar.dart | 4 +- .../widgets/buttons/app_back_button.dart | 2 +- .../widgets/buttons/theme_toggle_button.dart | 2 +- .../widgets/images/app_network_image.dart | 2 +- .../widgets/scaffold/app_scaffold.dart | 2 +- pubspec.lock | 16 +-- pubspec.yaml | 2 +- 59 files changed, 705 insertions(+), 565 deletions(-) create mode 100644 lib/core/common/app_navigator.dart create mode 100644 lib/core/common/theme/app_colors.dart rename lib/core/{ => common}/theme/theme.dart (100%) rename lib/core/{ => common}/theme/theme_cubit.dart (100%) create mode 100644 lib/core/configs/app_configs.dart rename lib/{data/datasources => core/database}/local_storage_datasource.dart (100%) create mode 100644 lib/core/failure/app_failure.dart delete mode 100644 lib/core/services/service_locator.dart delete mode 100644 lib/core/theme/app_colors.dart create mode 100644 lib/core/utils/app_utils.dart delete mode 100644 lib/data/datasources/movie_remote_data_source.dart rename lib/{domain => data/models}/entities/movie.dart (100%) rename lib/{domain => data/models}/entities/onboarding_item.dart (100%) create mode 100644 lib/data/models/enum/load_status.dart rename lib/data/models/{ => response}/movie_model.dart (94%) rename lib/data/models/{ => response}/movie_model.g.dart (100%) rename lib/data/models/{ => response}/movie_response_model.dart (100%) rename lib/data/models/{ => response}/movie_response_model.g.dart (100%) create mode 100644 lib/data/repositories/movie_repository.dart delete mode 100644 lib/data/repositories/movie_repository_impl.dart rename lib/data/repositories/{onboarding_repository_impl.dart => onboarding_repository.dart} (68%) delete mode 100644 lib/domain/repositories/movie_repository.dart delete mode 100644 lib/domain/repositories/onboarding_repository.dart delete mode 100644 lib/domain/usecases/check_onboarding_status.dart delete mode 100644 lib/domain/usecases/clear_onboarding_status.dart delete mode 100644 lib/domain/usecases/get_movie_details.dart delete mode 100644 lib/domain/usecases/get_popular_movies.dart delete mode 100644 lib/domain/usecases/save_onboarding_status.dart delete mode 100644 lib/presentation/detail/cubit/movie_detail_cubit.dart delete mode 100644 lib/presentation/detail/cubit/movie_detail_state.dart delete mode 100644 lib/presentation/home/cubit/home_cubit.dart delete mode 100644 lib/presentation/home/cubit/home_movie_cubit.dart delete mode 100644 lib/presentation/home/cubit/home_movie_state.dart delete mode 100644 lib/presentation/home/pages/home_page.dart delete mode 100644 lib/presentation/home/pages/movie_detail_page.dart rename lib/{core/navigator => router}/app_router.dart (53%) create mode 100644 lib/ui/pages/detail/cubit/movie_detail_cubit.dart create mode 100644 lib/ui/pages/detail/cubit/movie_detail_state.dart create mode 100644 lib/ui/pages/detail/navigator/movie_detail_navigator.dart create mode 100644 lib/ui/pages/detail/page/movie_detail_page.dart create mode 100644 lib/ui/pages/home/cubit/home_cubit.dart create mode 100644 lib/ui/pages/home/cubit/home_state.dart create mode 100644 lib/ui/pages/home/navigator/home_navigator.dart create mode 100644 lib/ui/pages/home/page/home_page.dart create mode 100644 lib/ui/pages/home/widgets/home_list_item.dart rename lib/{presentation => ui/pages}/onboarding/bloc/onboarding_bloc.dart (73%) rename lib/{presentation => ui/pages}/onboarding/bloc/onboarding_event.dart (100%) rename lib/{presentation => ui/pages}/onboarding/bloc/onboarding_state.dart (100%) rename lib/{presentation/onboarding/pages => ui/pages/onboarding/page}/onboarding_page.dart (92%) rename lib/{core => ui}/widgets/appbars/custom_app_bar.dart (100%) rename lib/{core => ui}/widgets/appbars/custom_sliver_app_bar.dart (81%) rename lib/{core => ui}/widgets/buttons/app_back_button.dart (89%) rename lib/{core => ui}/widgets/buttons/theme_toggle_button.dart (89%) rename lib/{core => ui}/widgets/images/app_network_image.dart (91%) rename lib/{core => ui}/widgets/scaffold/app_scaffold.dart (87%) diff --git a/lib/core/common/app_navigator.dart b/lib/core/common/app_navigator.dart new file mode 100644 index 0000000..4281804 --- /dev/null +++ b/lib/core/common/app_navigator.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class BaseNavigator { + final BuildContext context; + + BaseNavigator({required this.context}); + + Future pushNamed(String name, {Object? arguments}) async { + return context.pushNamed(name, extra: arguments); + } + + Future push(String location, {Object? arguments}) async { + return context.push(location, extra: arguments); + } + + void replaceNamed(String routeName, {Object? arguments}) async { + context.replaceNamed(routeName, extra: arguments); + } + + void goNamed(String routeName, {Object? arguments}) { + context.goNamed(routeName, extra: arguments); + } + + void go(String location, {Object? arguments}) { + context.go(location, extra: arguments); + } + + void replace(String routeName, {Object? arguments}) { + context.replace(routeName, extra: arguments); + } + + void pop() { + context.pop(); + } +} diff --git a/lib/core/common/theme/app_colors.dart b/lib/core/common/theme/app_colors.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/core/common/theme/app_colors.dart @@ -0,0 +1 @@ + diff --git a/lib/core/theme/theme.dart b/lib/core/common/theme/theme.dart similarity index 100% rename from lib/core/theme/theme.dart rename to lib/core/common/theme/theme.dart diff --git a/lib/core/theme/theme_cubit.dart b/lib/core/common/theme/theme_cubit.dart similarity index 100% rename from lib/core/theme/theme_cubit.dart rename to lib/core/common/theme/theme_cubit.dart diff --git a/lib/core/configs/app_configs.dart b/lib/core/configs/app_configs.dart new file mode 100644 index 0000000..c7ab4af --- /dev/null +++ b/lib/core/configs/app_configs.dart @@ -0,0 +1,5 @@ +class AppConfigs { + static const String appName = 'Flutter Base Project'; + static const String appVersion = '1.0.0'; + static const String appBuildNumber = '1'; +} diff --git a/lib/data/datasources/local_storage_datasource.dart b/lib/core/database/local_storage_datasource.dart similarity index 100% rename from lib/data/datasources/local_storage_datasource.dart rename to lib/core/database/local_storage_datasource.dart diff --git a/lib/core/failure/app_failure.dart b/lib/core/failure/app_failure.dart new file mode 100644 index 0000000..2ad57bf --- /dev/null +++ b/lib/core/failure/app_failure.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:equatable/equatable.dart'; + +// Base Failure class +abstract class Failure extends Equatable { + final String message; + + // You could add more properties like statusCode, stackTrace etc. + + const Failure(this.message); + + @override + List get props => [message]; + + @override + String toString() => message; // Simple representation +} + +// Specific Failure types (examples) +class ServerFailure extends Failure { + const ServerFailure({String message = 'An API error occurred'}) + : super(message); +} + +class NetworkFailure extends Failure { + const NetworkFailure({String message = 'Could not connect to the network'}) + : super(message); +} + +class CacheFailure extends Failure { + const CacheFailure({String message = 'Could not access local cache'}) + : super(message); +} + +class NotFoundFailure extends Failure { + const NotFoundFailure({String message = 'The requested item was not found'}) + : super(message); +} + +class UnexpectedFailure extends Failure { + const UnexpectedFailure({String message = 'An unexpected error occurred'}) + : super(message); +} + +class ApiFailure extends Failure { + const ApiFailure({String message = 'An unexpected error occurred'}) + : super(message); +} + +// Helper function to map exceptions to Failures (optional but useful) +Failure mapExceptionToFailure(dynamic e) { + // Add more specific exception handling (e.g., for DioError, SocketException) + if (e is FormatException) { + return ServerFailure(message: "Bad response format from server"); + } + + if (e is SocketException) { + return ServerFailure(message: "Network Error"); + } + + if (e is DioException) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.sendTimeout) { + return NetworkFailure(message: "Network Error"); + } + if (e.response?.data is Map) { + dynamic message = e.response?.data['message']; + if (message is List && message.isNotEmpty && message.first is String) { + return mapMessageKeyFailure(message.first); + } else if (message is String) { + return mapMessageKeyFailure(message); + } + } + } + + return UnexpectedFailure(message: "Error: $e"); +} + +Failure mapMessageKeyFailure(String? messageKey) { + if (messageKey == null) return const UnexpectedFailure(); + switch (messageKey) { + default: + return UnexpectedFailure(message: messageKey); + } +} diff --git a/lib/core/network/api_clients.dart b/lib/core/network/api_clients.dart index 9cda236..6ddb0e9 100644 --- a/lib/core/network/api_clients.dart +++ b/lib/core/network/api_clients.dart @@ -1,8 +1,8 @@ import 'package:dio/dio.dart'; import 'package:retrofit/retrofit.dart'; -import '../../data/models/movie_model.dart'; -import '../../data/models/movie_response_model.dart'; +import 'package:flutter_base_project/data/models/response/movie_model.dart'; +import 'package:flutter_base_project/data/models/response/movie_response_model.dart'; part 'api_clients.g.dart'; diff --git a/lib/core/services/service_locator.dart b/lib/core/services/service_locator.dart deleted file mode 100644 index e8b192c..0000000 --- a/lib/core/services/service_locator.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:get_it/get_it.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../data/datasources/local_storage_datasource.dart'; -import '../../data/repositories/onboarding_repository_impl.dart'; -import '../../domain/repositories/onboarding_repository.dart'; -import '../../domain/usecases/check_onboarding_status.dart'; -import '../../domain/usecases/clear_onboarding_status.dart'; -import '../../domain/usecases/save_onboarding_status.dart'; -import '../../presentation/home/cubit/home_cubit.dart'; -import '../../presentation/onboarding/bloc/onboarding_bloc.dart'; - -// Movie imports -import '../network/api_clients.dart'; -import '../network/dio_client.dart'; -import '../../data/datasources/movie_remote_data_source.dart'; -import '../../data/repositories/movie_repository_impl.dart'; -import '../../domain/repositories/movie_repository.dart'; -import '../../domain/usecases/get_popular_movies.dart'; -import '../../domain/usecases/get_movie_details.dart'; -import '../../presentation/home/cubit/home_movie_cubit.dart'; -import '../../presentation/detail/cubit/movie_detail_cubit.dart'; -import '../theme/theme_cubit.dart'; - -final sl = GetIt.instance; - -Future initServiceLocator() async { - // External - final sharedPreferences = await SharedPreferences.getInstance(); - sl.registerLazySingleton(() => sharedPreferences); - - // Data sources - sl.registerLazySingleton( - () => LocalStorageDataSourceImpl(sharedPreferences: sl()), - ); - - // Repository - sl.registerLazySingleton( - () => OnboardingRepositoryImpl(localDataSource: sl()), - ); - - // Use cases - sl.registerLazySingleton(() => CheckOnboardingStatus(repository: sl())); - sl.registerLazySingleton(() => SaveOnboardingStatus(repository: sl())); - sl.registerLazySingleton(() => ClearOnboardingStatus(repository: sl())); - - // Blocs - sl.registerFactory( - () => - OnboardingBloc(checkOnboardingStatus: sl(), saveOnboardingStatus: sl()), - ); - sl.registerFactory(() => HomeCubit(clearOnboardingStatus: sl())); - sl.registerLazySingleton(() => ThemeCubit()); - - // Setup Movie Dependencies - _setupMovieDependencies(); -} - -void _setupMovieDependencies() { - // Network - sl.registerLazySingleton(() => DioClient()); - sl.registerLazySingleton(() => ApiClients(sl().dio)); - - // Data sources - sl.registerLazySingleton( - () => MovieRemoteDataSourceImpl(apiClient: sl()), - ); - - // Repository - sl.registerLazySingleton( - () => MovieRepositoryImpl(remoteDataSource: sl()), - ); - - // Use cases - sl.registerLazySingleton(() => GetPopularMovies(sl())); - sl.registerLazySingleton(() => GetMovieDetails(sl())); - - // Blocs - sl.registerFactory(() => HomeMovieCubit(getPopularMovies: sl())); - sl.registerFactory(() => MovieDetailCubit(getMovieDetails: sl())); -} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/core/utils/app_utils.dart b/lib/core/utils/app_utils.dart new file mode 100644 index 0000000..a642bcb --- /dev/null +++ b/lib/core/utils/app_utils.dart @@ -0,0 +1 @@ +class AppUtils {} diff --git a/lib/data/datasources/movie_remote_data_source.dart b/lib/data/datasources/movie_remote_data_source.dart deleted file mode 100644 index 911e3c7..0000000 --- a/lib/data/datasources/movie_remote_data_source.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:dio/dio.dart'; -import '../../core/network/api_clients.dart'; -import '../models/movie_model.dart'; - -abstract class MovieRemoteDataSource { - Future> getPopularMovies(); - Future getMovieDetails(int id); -} - -class MovieRemoteDataSourceImpl implements MovieRemoteDataSource { - final ApiClients apiClient; - - MovieRemoteDataSourceImpl({required this.apiClient}); - - @override - Future> getPopularMovies() async { - try { - final response = await apiClient.getPopularMovies(); - return response.results ?? []; - } on DioException catch (e) { - throw Exception(e.message ?? 'Dio Exception occurred'); - } catch (e) { - throw Exception(e.toString()); - } - } - - @override - Future getMovieDetails(int id) async { - try { - return await apiClient.getMovieDetails(id); - } on DioException catch (e) { - throw Exception(e.message ?? 'Dio Exception occurred'); - } catch (e) { - throw Exception(e.toString()); - } - } -} diff --git a/lib/domain/entities/movie.dart b/lib/data/models/entities/movie.dart similarity index 100% rename from lib/domain/entities/movie.dart rename to lib/data/models/entities/movie.dart diff --git a/lib/domain/entities/onboarding_item.dart b/lib/data/models/entities/onboarding_item.dart similarity index 100% rename from lib/domain/entities/onboarding_item.dart rename to lib/data/models/entities/onboarding_item.dart diff --git a/lib/data/models/enum/load_status.dart b/lib/data/models/enum/load_status.dart new file mode 100644 index 0000000..4cbeb3e --- /dev/null +++ b/lib/data/models/enum/load_status.dart @@ -0,0 +1,16 @@ +enum LoadStatus { + initial, + loading, + success, + failure, + loadMore, + loadMoreSuccess, + loadMoreFailure; + + bool get isLoading => + this == LoadStatus.loading || this == LoadStatus.loadMore; + bool get isSuccess => + this == LoadStatus.success || this == LoadStatus.loadMoreSuccess; + bool get isFailure => + this == LoadStatus.failure || this == LoadStatus.loadMoreFailure; +} diff --git a/lib/data/models/movie_model.dart b/lib/data/models/response/movie_model.dart similarity index 94% rename from lib/data/models/movie_model.dart rename to lib/data/models/response/movie_model.dart index f2498ef..afc0f6d 100644 --- a/lib/data/models/movie_model.dart +++ b/lib/data/models/response/movie_model.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../domain/entities/movie.dart'; +import 'package:flutter_base_project/data/models/entities/movie.dart'; part 'movie_model.g.dart'; diff --git a/lib/data/models/movie_model.g.dart b/lib/data/models/response/movie_model.g.dart similarity index 100% rename from lib/data/models/movie_model.g.dart rename to lib/data/models/response/movie_model.g.dart diff --git a/lib/data/models/movie_response_model.dart b/lib/data/models/response/movie_response_model.dart similarity index 100% rename from lib/data/models/movie_response_model.dart rename to lib/data/models/response/movie_response_model.dart diff --git a/lib/data/models/movie_response_model.g.dart b/lib/data/models/response/movie_response_model.g.dart similarity index 100% rename from lib/data/models/movie_response_model.g.dart rename to lib/data/models/response/movie_response_model.g.dart diff --git a/lib/data/repositories/movie_repository.dart b/lib/data/repositories/movie_repository.dart new file mode 100644 index 0000000..1b24473 --- /dev/null +++ b/lib/data/repositories/movie_repository.dart @@ -0,0 +1,40 @@ +import 'package:dart_either/dart_either.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_base_project/core/failure/app_failure.dart'; +import 'package:flutter_base_project/core/network/api_clients.dart'; +import 'package:flutter_base_project/data/models/response/movie_model.dart'; + +abstract class MovieRepository { + Future>> getPopularMovies(); + Future> getMovieDetails(int id); +} + +class MovieRepositoryImpl implements MovieRepository { + final ApiClients apiClient; + + MovieRepositoryImpl({required this.apiClient}); + + @override + Future>> getPopularMovies() async { + try { + final response = await apiClient.getPopularMovies(); + return Right(response.results ?? []); + } on DioException catch (e) { + return Left(mapExceptionToFailure(e)); + } catch (e) { + return Left(mapExceptionToFailure(e)); + } + } + + @override + Future> getMovieDetails(int id) async { + try { + final result = await apiClient.getMovieDetails(id); + return Right(result); + } on DioException catch (e) { + return Left(mapExceptionToFailure(e)); + } catch (e) { + return Left(mapExceptionToFailure(e)); + } + } +} diff --git a/lib/data/repositories/movie_repository_impl.dart b/lib/data/repositories/movie_repository_impl.dart deleted file mode 100644 index bce1f82..0000000 --- a/lib/data/repositories/movie_repository_impl.dart +++ /dev/null @@ -1,29 +0,0 @@ -import '../../domain/entities/movie.dart'; -import '../../domain/repositories/movie_repository.dart'; -import '../datasources/movie_remote_data_source.dart'; - -class MovieRepositoryImpl implements MovieRepository { - final MovieRemoteDataSource remoteDataSource; - - MovieRepositoryImpl({required this.remoteDataSource}); - - @override - Future> getPopularMovies() async { - try { - return await remoteDataSource.getPopularMovies(); - } catch (e) { - // Typically you would handle exceptions and map them to domain Failures here. - // E.g., using fpdart's Either. For simplicity, just rethrowing. - rethrow; - } - } - - @override - Future getMovieDetails(int id) async { - try { - return await remoteDataSource.getMovieDetails(id); - } catch (e) { - rethrow; - } - } -} diff --git a/lib/data/repositories/onboarding_repository_impl.dart b/lib/data/repositories/onboarding_repository.dart similarity index 68% rename from lib/data/repositories/onboarding_repository_impl.dart rename to lib/data/repositories/onboarding_repository.dart index a8cd367..ff7e763 100644 --- a/lib/data/repositories/onboarding_repository_impl.dart +++ b/lib/data/repositories/onboarding_repository.dart @@ -1,5 +1,10 @@ -import '../../domain/repositories/onboarding_repository.dart'; -import '../datasources/local_storage_datasource.dart'; +import 'package:flutter_base_project/core/database/local_storage_datasource.dart'; + +abstract class OnboardingRepository { + Future hasCompletedOnboarding(); + Future completeOnboarding(); + Future clearOnboarding(); +} class OnboardingRepositoryImpl implements OnboardingRepository { final LocalStorageDataSource localDataSource; diff --git a/lib/domain/repositories/movie_repository.dart b/lib/domain/repositories/movie_repository.dart deleted file mode 100644 index dc042db..0000000 --- a/lib/domain/repositories/movie_repository.dart +++ /dev/null @@ -1,6 +0,0 @@ -import '../entities/movie.dart'; - -abstract class MovieRepository { - Future> getPopularMovies(); - Future getMovieDetails(int id); -} diff --git a/lib/domain/repositories/onboarding_repository.dart b/lib/domain/repositories/onboarding_repository.dart deleted file mode 100644 index fd3ec84..0000000 --- a/lib/domain/repositories/onboarding_repository.dart +++ /dev/null @@ -1,5 +0,0 @@ -abstract class OnboardingRepository { - Future hasCompletedOnboarding(); - Future completeOnboarding(); - Future clearOnboarding(); -} diff --git a/lib/domain/usecases/check_onboarding_status.dart b/lib/domain/usecases/check_onboarding_status.dart deleted file mode 100644 index a66b5b0..0000000 --- a/lib/domain/usecases/check_onboarding_status.dart +++ /dev/null @@ -1,11 +0,0 @@ -import '../repositories/onboarding_repository.dart'; - -class CheckOnboardingStatus { - final OnboardingRepository repository; - - CheckOnboardingStatus({required this.repository}); - - Future call() async { - return await repository.hasCompletedOnboarding(); - } -} diff --git a/lib/domain/usecases/clear_onboarding_status.dart b/lib/domain/usecases/clear_onboarding_status.dart deleted file mode 100644 index 5dfefef..0000000 --- a/lib/domain/usecases/clear_onboarding_status.dart +++ /dev/null @@ -1,11 +0,0 @@ -import '../repositories/onboarding_repository.dart'; - -class ClearOnboardingStatus { - final OnboardingRepository repository; - - ClearOnboardingStatus({required this.repository}); - - Future call() async { - return await repository.clearOnboarding(); - } -} diff --git a/lib/domain/usecases/get_movie_details.dart b/lib/domain/usecases/get_movie_details.dart deleted file mode 100644 index 0c2e5aa..0000000 --- a/lib/domain/usecases/get_movie_details.dart +++ /dev/null @@ -1,12 +0,0 @@ -import '../entities/movie.dart'; -import '../repositories/movie_repository.dart'; - -class GetMovieDetails { - final MovieRepository repository; - - GetMovieDetails(this.repository); - - Future execute(int id) async { - return await repository.getMovieDetails(id); - } -} diff --git a/lib/domain/usecases/get_popular_movies.dart b/lib/domain/usecases/get_popular_movies.dart deleted file mode 100644 index 2c1e6b2..0000000 --- a/lib/domain/usecases/get_popular_movies.dart +++ /dev/null @@ -1,12 +0,0 @@ -import '../entities/movie.dart'; -import '../repositories/movie_repository.dart'; - -class GetPopularMovies { - final MovieRepository repository; - - GetPopularMovies(this.repository); - - Future> execute() async { - return await repository.getPopularMovies(); - } -} diff --git a/lib/domain/usecases/save_onboarding_status.dart b/lib/domain/usecases/save_onboarding_status.dart deleted file mode 100644 index 5a9c8a3..0000000 --- a/lib/domain/usecases/save_onboarding_status.dart +++ /dev/null @@ -1,11 +0,0 @@ -import '../repositories/onboarding_repository.dart'; - -class SaveOnboardingStatus { - final OnboardingRepository repository; - - SaveOnboardingStatus({required this.repository}); - - Future call() async { - return await repository.completeOnboarding(); - } -} diff --git a/lib/main.dart b/lib/main.dart index f953546..3c88dc2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,36 +1,67 @@ import 'package:flutter/material.dart'; - import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flutter_base_project/router/app_router.dart'; +import 'package:flutter_base_project/core/common/theme/theme.dart'; +import 'package:flutter_base_project/core/common/theme/theme_cubit.dart'; + +import 'package:flutter_base_project/core/database/local_storage_datasource.dart'; +import 'package:flutter_base_project/data/repositories/onboarding_repository.dart'; -import 'core/navigator/app_router.dart'; -import 'core/services/service_locator.dart'; -import 'core/theme/theme.dart'; -import 'core/theme/theme_cubit.dart'; +import 'package:flutter_base_project/core/network/dio_client.dart'; +import 'package:flutter_base_project/core/network/api_clients.dart'; +import 'package:flutter_base_project/data/repositories/movie_repository.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await initServiceLocator(); - runApp(const MainApp()); + + final sharedPreferences = await SharedPreferences.getInstance(); + + runApp(MainApp(sharedPreferences: sharedPreferences)); } class MainApp extends StatelessWidget { - const MainApp({super.key}); + final SharedPreferences sharedPreferences; + + const MainApp({super.key, required this.sharedPreferences}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => sl(), - child: BlocBuilder( - builder: (context, themeMode) { - return MaterialApp.router( - title: 'Clean Architecture Onboarding', - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, - themeMode: themeMode, - routerConfig: AppRouter.router, - debugShowCheckedModeBanner: false, - ); - }, + return MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (context) => + LocalStorageDataSourceImpl(sharedPreferences: sharedPreferences), + ), + RepositoryProvider( + create: (context) => OnboardingRepositoryImpl( + localDataSource: context.read(), + ), + ), + RepositoryProvider(create: (context) => DioClient()), + RepositoryProvider( + create: (context) => ApiClients(context.read().dio), + ), + RepositoryProvider( + create: (context) => + MovieRepositoryImpl(apiClient: context.read()), + ), + ], + child: BlocProvider( + create: (context) => ThemeCubit(), + child: BlocBuilder( + builder: (context, themeMode) { + return MaterialApp.router( + title: 'Clean Architecture Onboarding', + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + routerConfig: AppRouter.router, + debugShowCheckedModeBanner: false, + ); + }, + ), ), ); } diff --git a/lib/presentation/detail/cubit/movie_detail_cubit.dart b/lib/presentation/detail/cubit/movie_detail_cubit.dart deleted file mode 100644 index 82888ef..0000000 --- a/lib/presentation/detail/cubit/movie_detail_cubit.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../domain/usecases/get_movie_details.dart'; -import 'movie_detail_state.dart'; - -class MovieDetailCubit extends Cubit { - final GetMovieDetails getMovieDetails; - - MovieDetailCubit({required this.getMovieDetails}) - : super(MovieDetailInitial()); - - Future fetchMovieDetails(int id) async { - emit(MovieDetailLoading()); - try { - final movie = await getMovieDetails.execute(id); - emit(MovieDetailLoaded(movie)); - } catch (e) { - emit(MovieDetailError(e.toString())); - } - } -} diff --git a/lib/presentation/detail/cubit/movie_detail_state.dart b/lib/presentation/detail/cubit/movie_detail_state.dart deleted file mode 100644 index a3589f6..0000000 --- a/lib/presentation/detail/cubit/movie_detail_state.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:equatable/equatable.dart'; -import '../../../../domain/entities/movie.dart'; - -abstract class MovieDetailState extends Equatable { - const MovieDetailState(); - - @override - List get props => []; -} - -class MovieDetailInitial extends MovieDetailState {} - -class MovieDetailLoading extends MovieDetailState {} - -class MovieDetailLoaded extends MovieDetailState { - final Movie movie; - - const MovieDetailLoaded(this.movie); - - @override - List get props => [movie]; -} - -class MovieDetailError extends MovieDetailState { - final String message; - - const MovieDetailError(this.message); - - @override - List get props => [message]; -} diff --git a/lib/presentation/home/cubit/home_cubit.dart b/lib/presentation/home/cubit/home_cubit.dart deleted file mode 100644 index 1d086dc..0000000 --- a/lib/presentation/home/cubit/home_cubit.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../domain/usecases/clear_onboarding_status.dart'; - -class HomeCubit extends Cubit { - final ClearOnboardingStatus clearOnboardingStatus; - - HomeCubit({required this.clearOnboardingStatus}) : super(null); - - Future resetOnboarding() async { - await clearOnboardingStatus(); - } -} diff --git a/lib/presentation/home/cubit/home_movie_cubit.dart b/lib/presentation/home/cubit/home_movie_cubit.dart deleted file mode 100644 index ea6fa6d..0000000 --- a/lib/presentation/home/cubit/home_movie_cubit.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../domain/usecases/get_popular_movies.dart'; -import 'home_movie_state.dart'; - -class HomeMovieCubit extends Cubit { - final GetPopularMovies getPopularMovies; - - HomeMovieCubit({required this.getPopularMovies}) : super(HomeMovieInitial()); - - Future fetchPopularMovies() async { - emit(HomeMovieLoading()); - try { - final movies = await getPopularMovies.execute(); - emit(HomeMovieLoaded(movies)); - } catch (e) { - emit(HomeMovieError(e.toString())); - } - } -} diff --git a/lib/presentation/home/cubit/home_movie_state.dart b/lib/presentation/home/cubit/home_movie_state.dart deleted file mode 100644 index c49d910..0000000 --- a/lib/presentation/home/cubit/home_movie_state.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:equatable/equatable.dart'; -import '../../../../domain/entities/movie.dart'; - -abstract class HomeMovieState extends Equatable { - const HomeMovieState(); - - @override - List get props => []; -} - -class HomeMovieInitial extends HomeMovieState {} - -class HomeMovieLoading extends HomeMovieState {} - -class HomeMovieLoaded extends HomeMovieState { - final List movies; - - const HomeMovieLoaded(this.movies); - - @override - List get props => [movies]; -} - -class HomeMovieError extends HomeMovieState { - final String message; - - const HomeMovieError(this.message); - - @override - List get props => [message]; -} diff --git a/lib/presentation/home/pages/home_page.dart b/lib/presentation/home/pages/home_page.dart deleted file mode 100644 index 19b8f74..0000000 --- a/lib/presentation/home/pages/home_page.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../../core/navigator/app_router.dart'; -import '../../../../core/services/service_locator.dart'; -import '../../../../core/widgets/buttons/theme_toggle_button.dart'; -import '../../../../core/widgets/images/app_network_image.dart'; -import '../../../../core/widgets/scaffold/app_scaffold.dart'; -import '../cubit/home_movie_cubit.dart'; -import '../cubit/home_movie_state.dart'; - -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => sl()..fetchPopularMovies(), - child: AppScaffold( - title: 'Popular Movies', - actions: const [ThemeToggleButton(), SizedBox(width: 8)], - body: BlocBuilder( - builder: (context, state) { - if (state is HomeMovieLoading) { - return const Center(child: CircularProgressIndicator()); - } else if (state is HomeMovieError) { - return Center(child: Text('Error: ${state.message}')); - } else if (state is HomeMovieLoaded) { - final movies = state.movies; - return GridView.builder( - padding: const EdgeInsets.all(8), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.7, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemCount: movies.length, - itemBuilder: (context, index) { - final movie = movies[index]; - return GestureDetector( - onTap: () { - context.pushNamed( - AppRouter.movieDetailName, - pathParameters: {'id': movie.id.toString()}, - ); - }, - child: Card( - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: AppNetworkImage( - imagePath: movie.posterPath ?? '', - fit: BoxFit.cover, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - movie.title ?? 'Unknown', - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ); - }, - ); - } - return const SizedBox.shrink(); - }, - ), - ), - ); - } -} diff --git a/lib/presentation/home/pages/movie_detail_page.dart b/lib/presentation/home/pages/movie_detail_page.dart deleted file mode 100644 index 72c5126..0000000 --- a/lib/presentation/home/pages/movie_detail_page.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../../core/services/service_locator.dart'; -import '../../../../core/widgets/appbars/custom_sliver_app_bar.dart'; -import '../../../../core/widgets/scaffold/app_scaffold.dart'; -import '../../detail/cubit/movie_detail_cubit.dart'; -import '../../detail/cubit/movie_detail_state.dart'; - -class MovieDetailPage extends StatelessWidget { - final int movieId; - - const MovieDetailPage({super.key, required this.movieId}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => sl()..fetchMovieDetails(movieId), - child: AppScaffold( - body: BlocBuilder( - builder: (context, state) { - if (state is MovieDetailLoading) { - return const Center(child: CircularProgressIndicator()); - } else if (state is MovieDetailError) { - return Center(child: Text('Error: ${state.message}')); - } else if (state is MovieDetailLoaded) { - final movie = state.movie; - return CustomScrollView( - slivers: [ - CustomSliverAppBar( - title: movie.title ?? 'Unknown', - imagePath: (movie.backdropPath?.isNotEmpty == true) - ? movie.backdropPath! - : (movie.posterPath ?? ''), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.star, color: Colors.amber), - const SizedBox(width: 4), - Text( - movie.voteAverage?.toStringAsFixed(1) ?? '0.0', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - Text( - 'Release: ${movie.releaseDate ?? 'Unknown'}', - style: const TextStyle(fontSize: 16), - ), - ], - ), - const SizedBox(height: 16), - const Text( - 'Overview', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - movie.overview ?? 'No overview available.', - style: const TextStyle(fontSize: 16, height: 1.5), - ), - ], - ), - ), - ), - ], - ); - } - return const SizedBox.shrink(); - }, - ), - ), - ); - } -} diff --git a/lib/core/navigator/app_router.dart b/lib/router/app_router.dart similarity index 53% rename from lib/core/navigator/app_router.dart rename to lib/router/app_router.dart index b59f500..183eb80 100644 --- a/lib/core/navigator/app_router.dart +++ b/lib/router/app_router.dart @@ -1,7 +1,7 @@ import 'package:go_router/go_router.dart'; -import '../../presentation/home/pages/home_page.dart'; -import '../../presentation/onboarding/pages/onboarding_page.dart'; -import '../../presentation/home/pages/movie_detail_page.dart'; +import 'package:flutter_base_project/ui/pages/home/page/home_page.dart'; +import 'package:flutter_base_project/ui/pages/onboarding/page/onboarding_page.dart'; +import 'package:flutter_base_project/ui/pages/detail/page/movie_detail_page.dart'; class AppRouter { static const String onboardingPath = '/'; @@ -10,7 +10,10 @@ class AppRouter { static const String homePath = '/home'; static const String homeName = 'home'; - static const String movieDetailPath = '/movie/:id'; + static const String movieDetailDeeplinkPath = + '/movie/:id'; // Path with parameter + static const String movieDetailDeeplinkName = 'movie_detail_deeplink'; + static const String movieDetailPath = '/movie'; // Path without parameter static const String movieDetailName = 'movie_detail'; static final router = GoRouter( @@ -27,12 +30,21 @@ class AppRouter { builder: (context, state) => const HomePage(), ), GoRoute( - path: movieDetailPath, - name: movieDetailName, + path: movieDetailDeeplinkPath, + name: movieDetailDeeplinkName, builder: (context, state) { final movieIdStr = state.pathParameters['id']; final movieId = int.tryParse(movieIdStr ?? '') ?? 0; - return MovieDetailPage(movieId: movieId); + return MovieDetailPage(args: MovieDetailArgument(movieId: movieId)); + }, + ), + GoRoute( + path: movieDetailPath, + name: movieDetailName, + builder: (context, state) { + final args = state.extra as MovieDetailArgument; + + return MovieDetailPage(args: args); }, ), ], diff --git a/lib/ui/pages/detail/cubit/movie_detail_cubit.dart b/lib/ui/pages/detail/cubit/movie_detail_cubit.dart new file mode 100644 index 0000000..08487b4 --- /dev/null +++ b/lib/ui/pages/detail/cubit/movie_detail_cubit.dart @@ -0,0 +1,28 @@ +import 'package:flutter_base_project/ui/pages/detail/navigator/movie_detail_navigator.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_base_project/data/models/enum/load_status.dart'; +import 'package:flutter_base_project/data/repositories/movie_repository.dart'; +import 'movie_detail_state.dart'; + +class MovieDetailCubit extends Cubit { + final MovieRepository repository; + final MovieDetailNavigator navigator; + + MovieDetailCubit({required this.repository, required this.navigator}) + : super(const MovieDetailState()); + + Future fetchMovieDetails(int id) async { + emit(state.copyWith(fetchMovieDetailsStatus: LoadStatus.loading)); + final result = await repository.getMovieDetails(id); + result.fold( + ifLeft: (failure) => + emit(state.copyWith(fetchMovieDetailsStatus: LoadStatus.failure)), + ifRight: (movie) => emit( + state.copyWith( + fetchMovieDetailsStatus: LoadStatus.success, + movie: movie, + ), + ), + ); + } +} diff --git a/lib/ui/pages/detail/cubit/movie_detail_state.dart b/lib/ui/pages/detail/cubit/movie_detail_state.dart new file mode 100644 index 0000000..d0accc1 --- /dev/null +++ b/lib/ui/pages/detail/cubit/movie_detail_state.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_base_project/data/models/entities/movie.dart'; +import 'package:flutter_base_project/data/models/enum/load_status.dart'; + +class MovieDetailState extends Equatable { + //Load Status + final LoadStatus fetchMovieDetailsStatus; + + //Movie + final Movie? movie; + + const MovieDetailState({ + this.fetchMovieDetailsStatus = LoadStatus.initial, + this.movie, + }); + + @override + List get props => [fetchMovieDetailsStatus, movie]; + + MovieDetailState copyWith({ + LoadStatus? fetchMovieDetailsStatus, + Movie? movie, + }) { + return MovieDetailState( + fetchMovieDetailsStatus: + fetchMovieDetailsStatus ?? this.fetchMovieDetailsStatus, + movie: movie ?? this.movie, + ); + } +} diff --git a/lib/ui/pages/detail/navigator/movie_detail_navigator.dart b/lib/ui/pages/detail/navigator/movie_detail_navigator.dart new file mode 100644 index 0000000..d23cc00 --- /dev/null +++ b/lib/ui/pages/detail/navigator/movie_detail_navigator.dart @@ -0,0 +1,5 @@ +import 'package:flutter_base_project/core/common/app_navigator.dart'; + +class MovieDetailNavigator extends BaseNavigator { + MovieDetailNavigator({required super.context}); +} diff --git a/lib/ui/pages/detail/page/movie_detail_page.dart b/lib/ui/pages/detail/page/movie_detail_page.dart new file mode 100644 index 0000000..e8ffe8a --- /dev/null +++ b/lib/ui/pages/detail/page/movie_detail_page.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_base_project/ui/pages/detail/navigator/movie_detail_navigator.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:flutter_base_project/ui/widgets/appbars/custom_sliver_app_bar.dart'; +import 'package:flutter_base_project/ui/widgets/scaffold/app_scaffold.dart'; +import 'package:flutter_base_project/ui/pages/detail/cubit/movie_detail_cubit.dart'; +import 'package:flutter_base_project/ui/pages/detail/cubit/movie_detail_state.dart'; +import 'package:flutter_base_project/data/repositories/movie_repository.dart'; + +class MovieDetailArgument { + final int movieId; + const MovieDetailArgument({required this.movieId}); +} + +class MovieDetailPage extends StatelessWidget { + final MovieDetailArgument args; + + const MovieDetailPage({super.key, required this.args}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => MovieDetailCubit( + repository: context.read(), + navigator: MovieDetailNavigator(context: context), + ), + child: MovieDetailChildPage(args: args), + ); + } +} + +class MovieDetailChildPage extends StatefulWidget { + final MovieDetailArgument args; + + const MovieDetailChildPage({super.key, required this.args}); + + @override + State createState() => _MovieDetailChildPageState(); +} + +class _MovieDetailChildPageState extends State { + late final MovieDetailCubit _cubit; + + @override + void initState() { + super.initState(); + _cubit = context.read(); + _cubit.fetchMovieDetails(widget.args.movieId); + } + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + body: BlocBuilder( + builder: (context, state) { + if (state.fetchMovieDetailsStatus.isLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state.fetchMovieDetailsStatus.isFailure) { + return Center(child: Text('Error')); + } else if (state.fetchMovieDetailsStatus.isSuccess) { + final movie = state.movie; + return CustomScrollView( + slivers: [ + CustomSliverAppBar( + title: movie?.title ?? 'Unknown', + imagePath: (movie?.backdropPath?.isNotEmpty == true) + ? movie!.backdropPath! + : (movie?.posterPath ?? ''), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.star, color: Colors.amber), + const SizedBox(width: 4), + Text( + movie?.voteAverage?.toStringAsFixed(1) ?? '0.0', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Text( + 'Release: ${movie?.releaseDate ?? 'Unknown'}', + style: const TextStyle(fontSize: 16), + ), + ], + ), + const SizedBox(height: 16), + const Text( + 'Overview', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + movie?.overview ?? 'No overview available.', + style: const TextStyle(fontSize: 16, height: 1.5), + ), + ], + ), + ), + ), + ], + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} diff --git a/lib/ui/pages/home/cubit/home_cubit.dart b/lib/ui/pages/home/cubit/home_cubit.dart new file mode 100644 index 0000000..f1045d0 --- /dev/null +++ b/lib/ui/pages/home/cubit/home_cubit.dart @@ -0,0 +1,43 @@ +import 'package:flutter_base_project/data/models/enum/load_status.dart'; +import 'package:flutter_base_project/data/repositories/onboarding_repository.dart'; +import 'package:flutter_base_project/ui/pages/detail/page/movie_detail_page.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_base_project/data/repositories/movie_repository.dart'; +import 'home_state.dart'; +import 'package:flutter_base_project/ui/pages/home/navigator/home_navigator.dart'; + +class HomeCubit extends Cubit { + final MovieRepository repository; + final HomeNavigator navigator; + final OnboardingRepository onboardingRepository; + + HomeCubit({ + required this.repository, + required this.navigator, + required this.onboardingRepository, + }) : super(const HomeState()); + + Future resetOnboarding() async { + await onboardingRepository.clearOnboarding(); + } + + Future fetchPopularMovies() async { + emit(state.copyWith(fetchPopularMoviesStatus: LoadStatus.loading)); + final result = await repository.getPopularMovies(); + result.fold( + ifLeft: (failure) => + emit(state.copyWith(fetchPopularMoviesStatus: LoadStatus.failure)), + ifRight: (movies) => emit( + state.copyWith( + fetchPopularMoviesStatus: LoadStatus.success, + movies: movies, + ), + ), + ); + } + + ///Navigator Methods + void openMovieDetail({required int movieId}) { + navigator.openMovieDetail(args: MovieDetailArgument(movieId: movieId)); + } +} diff --git a/lib/ui/pages/home/cubit/home_state.dart b/lib/ui/pages/home/cubit/home_state.dart new file mode 100644 index 0000000..fd50e71 --- /dev/null +++ b/lib/ui/pages/home/cubit/home_state.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_base_project/data/models/entities/movie.dart'; +import 'package:flutter_base_project/data/models/enum/load_status.dart'; + +class HomeState extends Equatable { + //LoadStatus + final LoadStatus fetchPopularMoviesStatus; + + //Data + final List movies; + + const HomeState({ + this.fetchPopularMoviesStatus = LoadStatus.initial, + this.movies = const [], + }); + + @override + List get props => [fetchPopularMoviesStatus, movies]; + + HomeState copyWith({ + LoadStatus? fetchPopularMoviesStatus, + List? movies, + }) { + return HomeState( + fetchPopularMoviesStatus: + fetchPopularMoviesStatus ?? this.fetchPopularMoviesStatus, + movies: movies ?? this.movies, + ); + } +} diff --git a/lib/ui/pages/home/navigator/home_navigator.dart b/lib/ui/pages/home/navigator/home_navigator.dart new file mode 100644 index 0000000..6575ceb --- /dev/null +++ b/lib/ui/pages/home/navigator/home_navigator.dart @@ -0,0 +1,12 @@ +import 'package:flutter_base_project/core/common/app_navigator.dart'; +import 'package:flutter_base_project/router/app_router.dart'; +import 'package:flutter_base_project/ui/pages/detail/page/movie_detail_page.dart'; +import 'package:go_router/go_router.dart'; + +class HomeNavigator extends BaseNavigator { + HomeNavigator({required super.context}); + + void openMovieDetail({required MovieDetailArgument args}) { + context.goNamed(AppRouter.movieDetailName, extra: args); + } +} diff --git a/lib/ui/pages/home/page/home_page.dart b/lib/ui/pages/home/page/home_page.dart new file mode 100644 index 0000000..a5cf2df --- /dev/null +++ b/lib/ui/pages/home/page/home_page.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_base_project/data/repositories/onboarding_repository.dart'; +import 'package:flutter_base_project/ui/pages/home/navigator/home_navigator.dart'; +import 'package:flutter_base_project/ui/pages/home/widgets/home_list_item.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_base_project/ui/widgets/buttons/theme_toggle_button.dart'; +import 'package:flutter_base_project/ui/widgets/scaffold/app_scaffold.dart'; +import 'package:flutter_base_project/ui/pages/home/cubit/home_cubit.dart'; +import 'package:flutter_base_project/ui/pages/home/cubit/home_state.dart'; +import 'package:flutter_base_project/data/repositories/movie_repository.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => HomeCubit( + repository: context.read(), + onboardingRepository: context.read(), + navigator: HomeNavigator(context: context), + ), + child: const HomeChildPage(), + ); + } +} + +class HomeChildPage extends StatefulWidget { + const HomeChildPage({super.key}); + + @override + State createState() => _HomeChildPageState(); +} + +class _HomeChildPageState extends State { + late final HomeCubit _cubit; + + @override + void initState() { + super.initState(); + _cubit = context.read(); + _cubit.fetchPopularMovies(); + } + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + title: 'Popular Movies', + actions: const [ThemeToggleButton(), SizedBox(width: 8)], + body: _buildBody(), + ); + } + + Widget _buildBody() { + return BlocBuilder( + builder: (context, state) { + if (state.fetchPopularMoviesStatus.isLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state.fetchPopularMoviesStatus.isFailure) { + return Center(child: Text('Error')); + } else if (state.fetchPopularMoviesStatus.isSuccess) { + final movies = state.movies; + return GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.7, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: movies.length, + itemBuilder: (context, index) { + final movie = movies[index]; + return HomeListItem( + movie: movie, + onTap: () { + if (movie.id != null) { + _cubit.openMovieDetail(movieId: movie.id!); + } + }, + ); + }, + ); + } + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/ui/pages/home/widgets/home_list_item.dart b/lib/ui/pages/home/widgets/home_list_item.dart new file mode 100644 index 0000000..9f9d12a --- /dev/null +++ b/lib/ui/pages/home/widgets/home_list_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_base_project/data/models/entities/movie.dart'; +import 'package:flutter_base_project/ui/widgets/images/app_network_image.dart'; + +class HomeListItem extends StatelessWidget { + final VoidCallback onTap; + final Movie movie; + const HomeListItem({super.key, required this.onTap, required this.movie}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: AppNetworkImage( + imagePath: movie.posterPath ?? '', + fit: BoxFit.cover, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + movie.title ?? 'Unknown', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/onboarding/bloc/onboarding_bloc.dart b/lib/ui/pages/onboarding/bloc/onboarding_bloc.dart similarity index 73% rename from lib/presentation/onboarding/bloc/onboarding_bloc.dart rename to lib/ui/pages/onboarding/bloc/onboarding_bloc.dart index 214416e..759d6ea 100644 --- a/lib/presentation/onboarding/bloc/onboarding_bloc.dart +++ b/lib/ui/pages/onboarding/bloc/onboarding_bloc.dart @@ -1,18 +1,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../domain/usecases/check_onboarding_status.dart'; -import '../../../domain/usecases/save_onboarding_status.dart'; +import 'package:flutter_base_project/data/repositories/onboarding_repository.dart'; import 'onboarding_event.dart'; import 'onboarding_state.dart'; class OnboardingBloc extends Bloc { - final CheckOnboardingStatus checkOnboardingStatus; - final SaveOnboardingStatus saveOnboardingStatus; + final OnboardingRepository repository; - OnboardingBloc({ - required this.checkOnboardingStatus, - required this.saveOnboardingStatus, - }) : super(OnboardingInitial()) { + OnboardingBloc({required this.repository}) : super(OnboardingInitial()) { on(_onCheckIfOnboardingCompleted); on(_onCompleteOnboarding); on(_onPageChanged); @@ -24,7 +18,7 @@ class OnboardingBloc extends Bloc { ) async { emit(OnboardingLoading()); try { - final hasCompleted = await checkOnboardingStatus(); + final hasCompleted = await repository.hasCompletedOnboarding(); emit(OnboardingLoaded(hasCompleted: hasCompleted)); } catch (e) { emit(const OnboardingError('Failed to check onboarding status.')); @@ -38,7 +32,7 @@ class OnboardingBloc extends Bloc { if (state is OnboardingLoaded) { final currentState = state as OnboardingLoaded; try { - await saveOnboardingStatus(); + await repository.completeOnboarding(); emit(currentState.copyWith(hasCompleted: true)); } catch (e) { emit(const OnboardingError('Failed to save onboarding status.')); diff --git a/lib/presentation/onboarding/bloc/onboarding_event.dart b/lib/ui/pages/onboarding/bloc/onboarding_event.dart similarity index 100% rename from lib/presentation/onboarding/bloc/onboarding_event.dart rename to lib/ui/pages/onboarding/bloc/onboarding_event.dart diff --git a/lib/presentation/onboarding/bloc/onboarding_state.dart b/lib/ui/pages/onboarding/bloc/onboarding_state.dart similarity index 100% rename from lib/presentation/onboarding/bloc/onboarding_state.dart rename to lib/ui/pages/onboarding/bloc/onboarding_state.dart diff --git a/lib/presentation/onboarding/pages/onboarding_page.dart b/lib/ui/pages/onboarding/page/onboarding_page.dart similarity index 92% rename from lib/presentation/onboarding/pages/onboarding_page.dart rename to lib/ui/pages/onboarding/page/onboarding_page.dart index 7598845..14c8a23 100644 --- a/lib/presentation/onboarding/pages/onboarding_page.dart +++ b/lib/ui/pages/onboarding/page/onboarding_page.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/services/service_locator.dart'; -import '../../../domain/entities/onboarding_item.dart'; -import '../bloc/onboarding_bloc.dart'; -import '../bloc/onboarding_event.dart'; -import '../bloc/onboarding_state.dart'; +import 'package:flutter_base_project/data/models/entities/onboarding_item.dart'; +import 'package:flutter_base_project/ui/pages/onboarding/bloc/onboarding_bloc.dart'; +import 'package:flutter_base_project/ui/pages/onboarding/bloc/onboarding_event.dart'; +import 'package:flutter_base_project/ui/pages/onboarding/bloc/onboarding_state.dart'; +import 'package:flutter_base_project/data/repositories/onboarding_repository.dart'; class OnboardingPage extends StatelessWidget { const OnboardingPage({super.key}); @@ -15,7 +15,8 @@ class OnboardingPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => - sl()..add(CheckIfOnboardingCompleted()), + OnboardingBloc(repository: context.read()) + ..add(CheckIfOnboardingCompleted()), child: const OnboardingView(), ); } diff --git a/lib/core/widgets/appbars/custom_app_bar.dart b/lib/ui/widgets/appbars/custom_app_bar.dart similarity index 100% rename from lib/core/widgets/appbars/custom_app_bar.dart rename to lib/ui/widgets/appbars/custom_app_bar.dart diff --git a/lib/core/widgets/appbars/custom_sliver_app_bar.dart b/lib/ui/widgets/appbars/custom_sliver_app_bar.dart similarity index 81% rename from lib/core/widgets/appbars/custom_sliver_app_bar.dart rename to lib/ui/widgets/appbars/custom_sliver_app_bar.dart index 31b2423..85c704b 100644 --- a/lib/core/widgets/appbars/custom_sliver_app_bar.dart +++ b/lib/ui/widgets/appbars/custom_sliver_app_bar.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../buttons/app_back_button.dart'; -import '../images/app_network_image.dart'; +import 'package:flutter_base_project/ui/widgets/buttons/app_back_button.dart'; +import 'package:flutter_base_project/ui/widgets/images/app_network_image.dart'; class CustomSliverAppBar extends StatelessWidget { final String title; diff --git a/lib/core/widgets/buttons/app_back_button.dart b/lib/ui/widgets/buttons/app_back_button.dart similarity index 89% rename from lib/core/widgets/buttons/app_back_button.dart rename to lib/ui/widgets/buttons/app_back_button.dart index 8705a3e..2792e02 100644 --- a/lib/core/widgets/buttons/app_back_button.dart +++ b/lib/ui/widgets/buttons/app_back_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../../navigator/app_router.dart'; +import 'package:flutter_base_project/router/app_router.dart'; class AppBackButton extends StatelessWidget { const AppBackButton({super.key}); diff --git a/lib/core/widgets/buttons/theme_toggle_button.dart b/lib/ui/widgets/buttons/theme_toggle_button.dart similarity index 89% rename from lib/core/widgets/buttons/theme_toggle_button.dart rename to lib/ui/widgets/buttons/theme_toggle_button.dart index 42731b1..9a06fe3 100644 --- a/lib/core/widgets/buttons/theme_toggle_button.dart +++ b/lib/ui/widgets/buttons/theme_toggle_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../theme/theme_cubit.dart'; +import 'package:flutter_base_project/core/common/theme/theme_cubit.dart'; class ThemeToggleButton extends StatelessWidget { const ThemeToggleButton({super.key}); diff --git a/lib/core/widgets/images/app_network_image.dart b/lib/ui/widgets/images/app_network_image.dart similarity index 91% rename from lib/core/widgets/images/app_network_image.dart rename to lib/ui/widgets/images/app_network_image.dart index 7b52991..3b68b04 100644 --- a/lib/core/widgets/images/app_network_image.dart +++ b/lib/ui/widgets/images/app_network_image.dart @@ -1,6 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import '../../network/api_constants.dart'; +import 'package:flutter_base_project/core/network/api_constants.dart'; class AppNetworkImage extends StatelessWidget { final String imagePath; diff --git a/lib/core/widgets/scaffold/app_scaffold.dart b/lib/ui/widgets/scaffold/app_scaffold.dart similarity index 87% rename from lib/core/widgets/scaffold/app_scaffold.dart rename to lib/ui/widgets/scaffold/app_scaffold.dart index 24e6685..dfebf85 100644 --- a/lib/core/widgets/scaffold/app_scaffold.dart +++ b/lib/ui/widgets/scaffold/app_scaffold.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../appbars/custom_app_bar.dart'; +import 'package:flutter_base_project/ui/widgets/appbars/custom_app_bar.dart'; class AppScaffold extends StatelessWidget { final String? title; diff --git a/pubspec.lock b/pubspec.lock index 1b0ca03..5c5c509 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_either: + dependency: "direct main" + description: + name: dart_either + sha256: "2c6f9bb4426c2f9e5109ca72e132e9d4f858167f9750dabd24a30c992c90db78" + url: "https://pub.dev" + source: hosted + version: "2.0.0" dart_style: dependency: transitive description: @@ -296,14 +304,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - get_it: - dependency: "direct main" - description: - name: get_it - sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" - url: "https://pub.dev" - source: hosted - version: "9.2.1" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 86f5b92..cfeeb2b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,11 +13,11 @@ dependencies: flutter: sdk: flutter flutter_bloc: ^9.1.1 - get_it: ^9.2.1 go_router: ^17.1.0 json_annotation: ^4.11.0 retrofit: ^4.9.2 shared_preferences: ^2.5.4 + dart_either: ^2.0.0 dev_dependencies: flutter_test: From e2e6ccf6372508bdf7c718f7dfe0123b821903e5 Mon Sep 17 00:00:00 2001 From: truongnq Date: Tue, 3 Mar 2026 13:12:30 +0700 Subject: [PATCH 7/8] refactor: update project structure in README.md --- README.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7aae922..7606323 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ A modern, responsive Flutter application showcasing a Clean Architecture impleme - **State Management**: [flutter_bloc](https://pub.dev/packages/flutter_bloc) - **Routing**: [go_router](https://pub.dev/packages/go_router) -- **Dependency Injection**: [get_it](https://pub.dev/packages/get_it) - **Networking**: [dio](https://pub.dev/packages/dio) & [retrofit](https://pub.dev/packages/retrofit) - **JSON Parsing**: [json_serializable](https://pub.dev/packages/json_serializable) & [json_annotation](https://pub.dev/packages/json_annotation) - **Data Comparison**: [equatable](https://pub.dev/packages/equatable) @@ -22,26 +21,30 @@ The project strictly follows **Clean Architecture** principles, dividing the cod ```text lib/ ├── core/ -│ ├── navigator/ # GoRouter configuration & routes +│ ├── common/ # Shared elements like AppTheme and ThemeCubit (Light/Dark Mode) +│ ├── configs/ # App-wide configurations and constants +│ ├── database/ # Local storage solutions (e.g., SharedPreferences) +│ ├── failure/ # Error handling and failure models │ ├── network/ # Dio client, API Constants, Retrofit ApiClients -│ ├── services/ # Service Locator (GetIt dependency injection config) -│ ├── theme/ # AppTheme, colors, and ThemeCubit (Light/Dark Mode) -│ └── widgets/ # Reusable, completely stateless UI components (AppBars, Buttons, Scaffold) +│ └── utils/ # Helper functions and utilities │ ├── data/ -│ ├── datasources/ # Remote (Retrofit) and Local (SharedPreferences) data sources -│ ├── models/ # Data Models bridging the Domain Entities to JSON structs -│ └── repositories/ # Concrete implementations of Domain Repository interfaces +│ ├── models/ # Data models (Responses, Entities, Enums) bridging JSON and UI +│ └── repositories/ # Concrete implementations for data fetching and caching │ -├── domain/ -│ ├── entities/ # Pure Dart objects representing core domain business data (e.g. Movie) -│ ├── repositories/ # Abstract interfaces mapping out Data demands -│ └── usecases/ # Specific actions the app can perform (e.g. GetPopularMovies) +├── router/ # GoRouter configuration and route definitions │ -├── presentation/ # Organized by Feature (e.g., home, detail, onboarding) -│ ├── detail/ # Movie detail screen and its cubits -│ ├── home/ # Home listing screen and its cubits -│ └── onboarding/ # First-time user experience and onboarding bloc +├── ui/ # User Interface layer (pages and shared widgets) +│ ├── pages/ # Organized by Feature (e.g., home, detail, onboarding) +│ │ ├── detail/ # Movie detail screen, cubit, and navigator +│ │ ├── home/ # Home listing screen, cubit, navigator, and local widgets +│ │ └── onboarding/ # First-time user experience and onboarding bloc +│ │ +│ └── widgets/ # Reusable, completely stateless UI components +│ ├── appbars/ # Custom AppBars +│ ├── buttons/ # Reusable buttons +│ ├── images/ # Network image wrappers +│ └── scaffold/ # Base scaffold widgets │ └── main.dart # Application entry point & root BlocProviders ``` From f2ce35cebe0f56e343d29b58cd376515eb010c28 Mon Sep 17 00:00:00 2001 From: truongnq Date: Tue, 3 Mar 2026 14:18:28 +0700 Subject: [PATCH 8/8] refactor: centralize network timeout configurations in AppConfigs --- lib/core/configs/app_configs.dart | 5 +++++ lib/core/network/dio_client.dart | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/core/configs/app_configs.dart b/lib/core/configs/app_configs.dart index c7ab4af..9d9ffb8 100644 --- a/lib/core/configs/app_configs.dart +++ b/lib/core/configs/app_configs.dart @@ -2,4 +2,9 @@ class AppConfigs { static const String appName = 'Flutter Base Project'; static const String appVersion = '1.0.0'; static const String appBuildNumber = '1'; + + //Network + static const Duration connectTimeout = Duration(seconds: 10); //connectTimeout + static const Duration receiveTimeout = Duration(seconds: 10); //receiveTimeout + static const Duration sendTimeout = Duration(seconds: 10); //sendTimeout } diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 5b3a0fe..4f39ba7 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_base_project/core/configs/app_configs.dart'; import 'api_constants.dart'; class DioClient { @@ -9,8 +10,9 @@ class DioClient { _dio = Dio( BaseOptions( baseUrl: ApiConstants.baseUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), + connectTimeout: AppConfigs.connectTimeout, + sendTimeout: AppConfigs.sendTimeout, + receiveTimeout: AppConfigs.receiveTimeout, queryParameters: {'api_key': ApiConstants.apiToken}, ), );