Skip to content

bedirhanssaglam/Movie-App-with-Modular-Architecture

Repository files navigation

Movie App with Modular Architecture in Flutter

Proje Görselleri

Bu proje, Veli Bacik tarafından oluşturulan ve sunulan mimari template üzerine oluşturulmuştur. Template büyük oranda aynıdır fakat bazı farkılılıklar mevcuttur. Bu sebeple, burada farklı, eksik ya da hatalı olabilecek bir kod dizini/anlatım template sahibine mal edilmemelidir.

Klasör yapısı:

📂 lib
  📂 development  
  📂 features   
  📂 product
  📄 main.dart
📂 module
  📂 common
  📂 gen
  📂 widget   
📂 test
  📂 models
  📂 network
  📂 view_model  
📄 build.yaml
📄 pubspec.yaml      

lib/

  • development: Bu klasör preview_main.dart dosyasını içerir. main.dart'tan farklı olarak Device Preview paketi vasıtasıyla projenin farklı cihazlarda görünümünü incelememize olanak sağlar.
runApp(
    DevicePreview(
      builder: (context) =>
          StateInitialize(child: ProductLocalization(child: const _MyApp())),
    ),
  );
  • features: Bu klasör uygulamanın sayfalarını ve yönetimini içerir. İçindeki bir klasörü (home) ele alacak olursak;
📂 features
 📂 home
  📂 view
   📂 mixin
    📄 home_view_mixin.dart
   📂 widget
    📄 home_appbar.dart
   📄 home_view.dart
  📂 view_model
   📂 mixin 
    📄 home_view_model_mixin.dart
   📄 home_view_model.dart 

view: view klasörü mixin ve widget klasörünü ve home_view.dart dosyasını içerir. mixin klasörü home_view_mixin.dart dosyasını içerir ve burada HomeView'da kullanılacak bazı işlevleri tanımlayabiliriz.

base mixin HomeViewMixin on State<HomeView> {
  late final HomeViewModel _homeViewModel;

  HomeViewModel get homeViewModel => _homeViewModel;

  @override
  void initState() {
    super.initState();
    _homeViewModel = HomeViewModel(
      operationService: MovieService(ProductStateItems.productNetworkManager),
    )..fetchTopRatedMovies();
  }
}

⚠️ base mixin, with ile kullanıma izin verir ve kullanıldığı yerin final olması gerekir. Daha detaylı bilgi için Dart dokümantasyonundaki Class modifiers reference bölümünü inceleyiniz.

Widget klasörü bu sayfaya (home) özgü widget'ları kapsar.

home_view.dart kullanıcı arayüzünü oluşturan kodların bulunduğu dosyadır.

view_model: view_model klasörü bizim business kodlarımızı içerir.

/// Manage home view business logic
final class HomeViewModel extends BaseCubit<BaseState<Movie>> {
  /// [MovieOperation] service
  HomeViewModel({
    required MovieOperation operationService,
  })  : _movieOperationService = operationService,
        super(const BaseState.initial());

  final MovieOperation _movieOperationService;

  /// Fetch top rated movies
  Future<void> fetchTopRatedMovies() async {
    emit(const BaseState<Movie>.loading());
    try {
      final response = await _movieOperationService.fetchTopRatedMovies();
      emit(BaseState<Movie>.success(response!));
    } catch (_) {
      emit(BaseState<Movie>.error(LocaleKeys.home_error.localize));
    }
  }
}

Burada Cubit kullanılarak HomeViewModel oluşturuluyor ve en çok oylanan filmler servis isteğindeki duruma göre ilgili state'i emit ediyor.

⚠️ Buradaki BaseState bu projeye özgüdür. Bu projede her sayfada tek bir servis çalıştığı için yazılmıştır. Aynısını farklı bir projede kullanmanız sorunlara yol açabilir, bilindik yollara başvurmak daha sağlıklı olacaktır.

  • product: product klasörü oldukça kapsamlıdır. Projenin bazı katmanlarını burada yönetiriz. İçinde 6 klasör bulunmaktadır;
📂 product
 📂 init
 📂 routes
 📂 service
 📂 state
 📂 utility
 📂 widget

init: init klasörü environmentları, çoklu dil desteği ve tema yönetimi gibi işlevleri içerir.

routes: routes klasörü yönlendirme işlerinin yönetildiği kısımdır. Burada auto_route paketi kullanılmıştır.

service: service klasörü isteklerin yönetildiği yerdir. Servisler vexana paketiyle yönetilmiştir. Bu paket, dio üzerine yazılmıştır. Ayrıca sade bir kullanım sunar.

📂 init
 📂 service
  📂 interceptor
  📂 interface
  📂 manager
   📄 product_network_manager.dart
  📄 movie_service.dart 

product_network_manager.dart şöyledir:

typedef OnErrorStatus = ValueChanged<int>;

/// Product network manager
final class ProductNetworkManager extends NetworkManager<EmptyModel> {
  ProductNetworkManager.base()
      : super(
          options: BaseOptions(
            baseUrl: AppEnvironmentItems.baseUrl.value,
            queryParameters: {
              AppConstants.apiKeyParameter: AppEnvironmentItems.apiKey.value,
            },
          ),
        );

  /// Handle error
  /// [onErrorStatus] is error status code [HttStatus]
  void listenErrorState({required OnErrorStatus onErrorStatus}) {
    interceptors.add(
      ProductInterceptor(onErrorStatus: onErrorStatus),
    );
  }
}

Network manager'a optionslar ve interceptorlar ekleyebiliriz. :warning: Bu projedeki tüm servis isteklerinde api_key query parametresi zorunlu olduğundan burada verilmiştir.

state: state klasörü base, container ve view_model klasörlerini içerir ve projenin ana state katmanı, locatorlar ve projenin tamamında geçerli state yönetimi (örn. temanın yönetimi) burada yapılır.

utility: utility klasörü constants, enums ve extensions klasörlerini içerir. Ayrıca, projede kullanılan BorderRadius'ların bulunduğu app_border_radius.dart dosyasını da içerir.

widget: Burada proje genelinde kullanılan widgetlar (örn.AppBottomNavigationBar) bulunur. Ek olarak, paket ile kazanılan bazı widgetlar (örn. ProjectCachedImage) module altındaki widgets klasöründe oluşturulup burada tanımlanır ve projede kullanılırken buradan çağırılır.

⚠️ bloc ve list_view klasörleri içerisindeki widgetlar bu projeye özgüdür. Bu projede kod tekrarının önüne geçip projenin her yerinde sorunsuz çalışmaktadır.

module/

  • common: Burada paketler ile elde ettiğimiz 2 widget'ı (carousel_slider ve cached_network_image) daha sonra lib/product/widget altında kullanmak üzere burada tanımladık. Bunlardan CarouselSliderBuilder'ı inceleyelim;
 📂 common
  📂 lib
   📂 src
    📂 package
     📂 carousel_slider
      📄 carousel_slider_builder.dar
      📄 carousel_builder_size.dart
      📄 index.dart
  📄 pubspec.yaml      

pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  # Slider widget item
  carousel_slider: ^4.2.1  
  # Network image to cache
  cached_network_image: ^3.3.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  # Lint rules  
  very_good_analysis: ^5.1.0
import 'package:carousel_slider/carousel_slider.dart';
import 'package:common/src/package/carousel_slider/carousel_slider_size.dart';
import 'package:flutter/material.dart';

typedef CarouselWidgetBuilder = Widget Function(int index);

final class CarouselSliderBuilder extends StatelessWidget {
  const CarouselSliderBuilder({
    required this.widgetBuilder,
    required this.itemCount,
    super.key,
    this.enlargeCenterPage,
    this.carouselSliderSize = const CarouselSliderSize(),
    this.autoPlay = false,
    this.disableCenter = true,
  });

  final int itemCount;
  final CarouselWidgetBuilder widgetBuilder;
  final CarouselSliderSize carouselSliderSize;
  final bool autoPlay;
  final bool disableCenter;
  final bool? enlargeCenterPage;

  @override
  Widget build(BuildContext context) {
    return CarouselSlider.builder(
      options: CarouselOptions(
        enlargeCenterPage: enlargeCenterPage,
        disableCenter: disableCenter,
        autoPlay: autoPlay,
        scrollDirection: scrollDirection,
        scrollPhysics: const BouncingScrollPhysics(),
        aspectRatio: carouselSliderSize.aspectRatio,
        viewportFraction: carouselSliderSize.viewportFraction,
      ),
      itemCount: itemCount,
      itemBuilder: (context, index, _) => widgetBuilder(index),
    );
  }
}

Daha sonra lib/product/widget/carousel_slider/project_carousel_slider.dart dosyasında;

import 'package:common/common.dart';
import 'package:flutter/material.dart';

final class ProjectCarouselSlider extends StatelessWidget {
  const ProjectCarouselSlider({
    required this.widgetBuilder,
    required this.itemCount,
    super.key,
  });

  final Widget Function(int) widgetBuilder;
  final int itemCount;

  @override
  Widget build(BuildContext context) {
    return CarouselSliderBuilder(
      itemCount: itemCount,
      widgetBuilder: widgetBuilder,
      enlargeCenterPage: true,
    );
  }
}
  • gen: Burada modellerin, assetlerin ve environmentsların generate işlemlerini tutarız. Klasör yapısı şöyledir;
 📂 gen
  📂 asset
   📂 environments
   📂 icons
   📂 lottie
  📂 lib
   📂 src
    📂 asset
    📂 environments
     📄 dev_env.dart
     📄 dev_env.g.dart
     📄 environment_configuration.dart
     📄 index.dart
    📂 models
  📄 gen.dart
  📄 pubspec.yaml 

Buradaki environment yönetimini ele alalım. module/gen/asset/environments/.dev.env dosyasında;

BASE_URL = https://api.themoviedb.org/3/
API_KEY = YOUR_API_KEY

API_KEY'i Movie Database'i ziyaret edip alabilirsiniz.

environment_configuration.dart dosyasında bir abstract class oluştururuz.

/// The above class is an abstract interface class for app configuration.
abstract class EnvironmentConfiguration {
  /// it using from network manager
  String get baseUrl;

  /// it using from movie api key
  String get apiKey;
}
import 'package:envied/envied.dart';
import 'package:gen/src/environments/environment_configuration.dart';

part 'dev_env.g.dart';

@Envied(path: 'asset/environments/.dev.env', obfuscate: true)
/// Production environment variables
final class DevEnv implements EnvironmentConfiguration {
  @EnviedField(varName: 'BASE_URL')
  static final String _baseUrl = _DevEnv._baseUrl;

  @EnviedField(varName: 'API_KEY')
  static final String _apiKey = _DevEnv._apiKey;

  @override
  String get baseUrl => _baseUrl;

  @override
  String get apiKey => _apiKey;
}

Bu işlemler için envied, envied_generator ve build_runner paket kullandık. Bağımlılıkları projeye eklemek için aşağıdaki komutları çalıştırın.

$ flutter pub add envied
$ flutter pub add --dev envied_generator
$ flutter pub add --dev build_runner

ÖNEMLİ! Hem .env hem de env.g.dart dosyalarını .gitignore dosyanıza ekleyin, aksi takdirde ortam değişkenlerinizi açığa çıkarabilirsiniz.

Oluşturduğunuz dosyayı generate etmek için aşağıdaki komutu terminalden çalıştırmalısınız.

dart run build_runner build

Daha sonra lib/product/config/app_environment.dart dosyası altında bunun tanımlamasını yaparız.

import 'package:gen/gen.dart';

/// Application environment manager class
final class AppEnvironment {
  /// Setup application environment
  AppEnvironment.setup({required EnvironmentConfiguration config}) {
    _config = config;
  }

  /// General application environment setup
  AppEnvironment.general() {
    _config = DevEnv();
  }

  static late final EnvironmentConfiguration _config;
}

/// Get application environment items
enum AppEnvironmentItems {
  /// Network base url
  baseUrl,

  /// Movie api key
  apiKey;

  /// Get application environment item value
  String get value {
    try {
      switch (this) {
        case AppEnvironmentItems.baseUrl:
          return AppEnvironment._config.baseUrl;
        case AppEnvironmentItems.apiKey:
          return AppEnvironment._config.apiKey;
      }
    } catch (_) {
      throw Exception('AppEnvironment is not initialized.');
    }
  }
}

Son olarak runApp'ten önce bunu çağırırız. Eğer network test oluşturuyorsak bunu setUp'ta çağırırız.

void main() {
  late final ProductNetworkManager manager;
  setUp(() {
    AppEnvironment.general();
    manager = ProductNetworkManager.base();
  });
  test('fetch top rated movies from api', () async {
    final response = await manager.send<Movie, Movie>(
      ProductServicePath.topRated.path,
      parseModel: Movie(),
      method: RequestType.GET,
    );

    expect(response.data, isNotNull);
    expect(response.data!.results, isNotEmpty);
  });
}
  • widgets: Burada atomic widgetlar yer alır. Örneğin uygulamada kullanılacak base dialog'u ve responsive için kullandığımız yapıyı burada tanımlarız.
import 'package:flutter/material.dart' show Size;

/// Design size for project
enum DesignSize {
  designSize(Size(390, 844));

  const DesignSize(this.size);

  final Size size;
}
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:widgets/src/utility/enums/design_size.dart';

/// Custom responsive for project
class CustomResponsive extends StatelessWidget {
  const CustomResponsive({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return ScreenUtilInit(
      designSize: DesignSize.designSize.size,
      minTextAdapt: true,
      splitScreenMode: true,
      builder: (BuildContext context, _) => child,
    );
  }
}

Ve daha sonra;

import 'package:widgets/widgets.dart';

final class _MyApp extends StatelessWidget {
  const _MyApp();

  static final _appRouter = AppRouter();
  @override
  Widget build(BuildContext context) {
    return CustomResponsive( // from widgets module
      child: MaterialApp.router(
        debugShowCheckedModeBanner: false,
        title: AppConstants.appName,
        routerConfig: _MyApp._appRouter.config(),
        theme: LightThemeManager().themeData,
        darkTheme: DarkThemeManager().themeData,
        themeMode: context.watch<ProductViewModel>().state.themeMode,
        localizationsDelegates: context.localizationDelegates,
        supportedLocales: context.supportedLocales,
        locale: context.locale,
      ),
    );
  }
}

test/ Burada testlerimiz yer alır. Model, servis istekleri ve ViewModel'lar için testler yazılmıştır. Klasör ve dosyalama aşağıdaki gibidir.

CategoryViewModel için testi inceleyelim.

📂 test
  📂 view_model
   📂 category
    📄 category_service_mock.dart 
    📄 category_view_model_test.dart
   📂 details
   📂 discover
   📂 home

Testler için bize üç bağımlılık gerekiyor.

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.0.2
  bloc_test: ^9.1.4

mockito ve bloc_test bağımlılıklarını ekledikten sonra category_service_mock.dart dosyasını oluşturuyoruz. Burada bu paketlerin kullanımı ve detayları anlatılmayacaktır.

import 'package:gen/gen.dart';
import 'package:mockito/mockito.dart';
import 'package:moviemodular/product/service/interface/movie_operation.dart';

final class CategoryServiceMock extends Mock implements MovieOperation {
  @override
  Future<Genres> fetchAllCategories() async {
    return Genres(
      genres: [
        GenresData(id: 1, name: 'Action'),
        GenresData(id: 2, name: 'Comedy'),
      ],
    );
  }
}

Artık CategoryViewModel'ı test edebiliriz.

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gen/gen.dart';
import 'package:moviemodular/features/category/view_model/category_view_model.dart';
import 'package:moviemodular/product/state/base/base_state.dart';

import 'category_service_mock.dart';

void main() {
  late final CategoryViewModel categoryViewModel;

  final mockGenresItem = Genres(
    genres: [
      GenresData(id: 1, name: 'Action'),
      GenresData(id: 2, name: 'Comedy'),
    ],
  );

  setUp(() {
    categoryViewModel =
        CategoryViewModel(operationService: CategoryServiceMock());
  });

  group('CategoryViewModel', () {
    test('initial state is BaseState<Genres>.initial', () {
      expect(
        categoryViewModel.state,
        equals(const BaseState<Genres>.initial()),
      );
    });

    blocTest<CategoryViewModel, BaseState<Genres>>(
      'emits [BaseState<Genres>.loading, BaseState<Genres>.success] '
      'when categories are loaded successfully',
      build: () => categoryViewModel,
      act: (viewModel) async => viewModel.fetchAllCategories(),
      expect: () => [
        const BaseState<Genres>.loading(),
        BaseState<Genres>.success(mockGenresItem),
      ],
    );
  });
}

Önce gerekli kurulumları setUp içerisinde yaptık ve daha sonra ViewModel'ın davranışını State üzerinden kontrol ettik.

SON


Burada yer almayan kodlar için projeyi inceleyebilir, arkasındaki düşünce ve kodlar üzerinde daha fazla bilgi için Flutter Architecture Template v2 (Türkçe) oynatma listesini izleyebilirsiniz. Bu proje, oynatma listesindeki yaklaşım üzerinde pratik ve farklı bazı denemeler (BaseBlocWidget gibi) yapmak için oluşturulmuştur.

Bir hata olduğunu düşünüyorsanız fikirlerinizi paylaşmaktan çekinmeyin. Okuduğunuz için teşekkürler! 🙂

About

Movie App with Modular Architecture in Flutter

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages