Flutter i MVVM wyjaśniony w jednym artykule (plus moja implementacja)

Pokażę Ci jak zaimplementować wzorzec MVVM we Flutterze. Dzięki użyciu biblioteki Provider wykonamy aplikację Flutter MVVM.

Flutter i MVVM wyjaśniony w jednym artykule (plus moja implementacja)

Długo się do tego zbierałem, ale stało się! W tym artykule pokażę Ci jak połączyć framework Flutter i MVVM.

Wywodzę się ze świata Xamarina. Moją codziennością była praca z frameworkiem MVVMCross a pewne rozwiązania dostępne w nim niesamowicie mi odpowiadają. Toteż na co dzień siłą rzeczy implementowałem podstawowe funkcjonalności oraz dodawałem wiele od siebie, bazując na swoim doświadczeniu jako Flutter developer na własnych włościach.

Co więcej, zauważyłem, że wiele rzeczy powtarza się przy tworzeniu aplikacji mobilnych i mam tu na myśli rozwiązania architektoniczne, użycie powtarzalnych zależności (niezależnie od rodzaju tworzonej aplikacji) ogólnie rzecz biorąc pewien specyficzny dla mnie sposób pracy z Flutterem. Dlatego to, co Ci przedstawię w tym artykule jest częścią mojego frameworka do tworzenia aplikacji w tej technologii.

O tym, jak połączyłem framework Flutter i MVVM  przedstawię Ci, tworząc prostą aplikację składającą się tylko z dwóch ekranów: listy oraz detali wybranej pozycji.

Koncepcja, którą przedstawiam w tym artykule, może zawierać pewne dziury czy edge-case’y. Na potrzeby tworzonych przeze mnie aplikacji daje radę, ale biorę pod uwagę, że może pojawić się przypadek, że coś nie zadziała, jak należy.

Daj, proszę znać, jak coś takiego zauważysz. Chętnie poznam Twój punkt widzenia.

Piona! ✋

Flutter i MVVM. Co składa się na całość rozwiązania?

  • Bazowy model widoku.
  • Silnie typowany stan modelu widoku, który udostępniany jest widokowi.
  • Bazowy Page z silnie typowanym modelem widoku.
  • Silnie typowany parametr inicjujący w ViewModel.
  • Implementacja wzorca MVVM z wykorzystaniem biblioteki provider.
  • Komunikacja modelu widoku z widokiem oparta o użycie ValueNotifier i obiektu stanu.

Co jest Ci potrzebne?

Rozwiązanie opieram o kilka bibliotek.

  • equatable — do łatwiejszego porównywania obiektów,
  • provider — biblioteka ułatwiająca zarządzanie stanem.
  • get_it – do zarządzania zależnościami,
  • copy_with_extension – do łatwiejszego tworzenia nowego obiektu na podstawie innego.

Poniżej znajdziesz listę bibliotek do dodania do pliku pubspec.yaml.

dependencies:
  flutter:
    sdk: flutter

  equatable: ^2.0.5
  provider: ^6.0.5
  get_it: 7.2.0
  copy_with_extension: ^5.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  build_runner: ^2.3.2
  copy_with_extension_gen: ^5.0.0

Proponowana struktura projektu Flutter

Na poniższym zrzucie ekranu zobaczysz strukturę projektu, którą wykorzystałem na cele tego artykułu. Jakiś czas temu uznałem, że sprawdza mi się przechowywanie modelu widoku, klasy stanu oraz widoku w jednym folderze, którego nazwa opisuje jeden z ekranów. Oczywiście pojawia się potrzeba przechowywania naszych dedykowanych widżetów. Wtedy umieszczam je w folderze widgets w  konkretnej funkcjonalności.

Struktura projektu

Bazowy ViewModel

Moje podejście zakłada istnienie dwóch bazowych modeli widoków.

Pierwszy z nich bazowy dla całości rozwiązania. Zawiera on podstawowe, podstawowe funkcjonalności. Definiuje on interfejs, udostępnia logikę przetwarzania stanu oraz enkapsuluje użycie ValueNotifier<T>.  Nie zawiera logiki biznesowej konkretnej aplikacji.

Drugi jest tym specyficznym bazowym modelem widoku dla aplikacji. Rozszerza on ten poprzedni wymienoony. Dzięki temu możemy zawrzeć w nim logikę dedykowaną konkretnej aplikacji tj. użycie zależności czy udostępnienie metod dla konkretnych modeli widoków.

Podsumowując, zadaniem pierwszego jest udostępnienie interfejsu, natomiast drugi jest bazą używaną w konkretnej aplikacji.

Bazowy view model

Poniżej kompletna implementacja bazowego modelu widoku. Głównym jego założeniem jest brak logiki związanej z konkretną aplikacją.

Po kolei.

Pierwszy parametr TInitParameter odpowiada za typ obiektu, który przekazujemy jako argument wejściowy podczas nawigacji do widoku powiązanego z tym modelem widoku. Oznacza to po prostu jakieś dane, które są Ci potrzebne na starcie np. id detalu lub dane wybrane przez użytkownika na poprzednim widoku.

W ramach dodanego założenia VMParameter musi być typu StateData. Oznacza on obiekt, który jest stanem modelu widoku. Konkretna klasa powinna zawierać wszelkie propercje, które są potrzebne do komunikacji pomiędzy widokiem a modelem widoku. Dla przykładu może to być lista obiektów, które wyświetlimy na widoku.

Za chwilę stanie się to jaśniejsze.

import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';

abstract class PBViewModelBase<TInitParameter, TVMParameter extends StateData>
    extends ValueListenable<TVMParameter> {
  PBViewModelBase(TVMParameter initial)
      : _stateDataNotifier = ValueNotifier(initial) {
    stateData = initial;
  }

  late TVMParameter _stateData;

  TVMParameter get stateData => _stateData;

  Future<void> initialize(TInitParameter parameter);

  @mustCallSuper
  void onDispose() {
    _stateDataNotifier.dispose();
    debugPrint("$runtimeType disposed...");
  }

  final ValueNotifier<TVMParameter> _stateDataNotifier;

  @protected
  set stateData(TVMParameter value) {
    _stateData = value;
    _stateDataNotifier.value = value;
  }

  @override
  TVMParameter get value => _stateDataNotifier.value;

  @override
  void addListener(VoidCallback listener) {
    _stateDataNotifier.addListener(listener);
  }

  @override
  void removeListener(VoidCallback listener) {
    _stateDataNotifier.removeListener(listener);
  }
}

abstract class StateData extends Equatable {
  const StateData();
}

class NoParam {}

Zaimplementowana logika pozwala na informowanie widoku o zmianach za każdym razem, gdy stan się zmieni. Co w skrócie oznacza zmianę obiektu stateData.

Bazowy model widoku dla aplikacji

Jestem skłonny powiedzieć, że jest to rzecz opcjonalna, ale w dłuższej perspektywie pomoże Ci zachować porządek w projekcie. Ten bazowy model widoku korzysta z poprzedniego, ale posiada referencję do NavigationService, który pozwoli nam na nawigację z wnętrza modelu widoku.

import 'package:pb_flutter_mvvm_provider/di/di.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_base.dart';
import 'package:pb_flutter_mvvm_provider/services/navigation_service.dart';

abstract class ViewModelBase<TParameter, TVMParameter extends StateData>
    extends PBViewModelBase<TParameter, TVMParameter> {
  late TParameter parameter;

  final NavigationService navigationService = sl();

  ViewModelBase(TVMParameter initial) : super(initial);
}

Poniżej znajduje się naiwna implementacja serwisu do nawigacji. W swoich projektach korzystam z bardziej rozbudowanej logiki, ale na potrzeby przykładu uprościłem ją. Zakładam, że kod nie wymaga dodatkowych wyjaśnień.

class NavigationService {
  final GlobalKey<NavigatorState> navigatorKey;

  NavigationService(this.navigatorKey);

  Future<T> navigateToPage<T>(Widget destination, {Object? argument}) async {
    return await navigatorKey.currentState?.push(CupertinoPageRoute(
        builder: (context) => destination,
        settings: RouteSettings(
            name: destination.runtimeType.toString(), arguments: argument)));
  }
}

Bazowy widok

Kolejna cegiełka w naszym podejściu Flutter MVVM. Kod bazowego widoku jest lekko bardziej skomplikowany. Moje założenie jest takie, że page jest zawsze typu StatefulWidget. Stąd użycie tego typu jako bazy. Podczas nawigacji możemy przekazać odpowiedni obiekt, stąd typ TInitParameter.

Tworząc nowy widok (page’a), musimy użyć ParamStatefulWidget<> klasy widgetu oraz PBMVVMPage<> dla klasy stanu. Deklaracja bazy i ograniczeń jest stosunkowo skomplikowana, ale to pozwoli nam na używanie silnego typowania w konkretnych widokach.

Jeśli dany widok nie wymaga parametru wejściowego, należy użyć obiektu typu NoParam. Chciałem w ten sposób uniknąć null -parametrów.

W metodzie initState()  prosimy ViewModelFactory o nowy model widoku oraz wywołujemy metodę initialize() wraz z parametrem.

Metoda buildContent() jest abstrakcyjna i musi być zaimplementowana w konkretnym widoku. Ma za zadanie zwrócić widok. Domyślna metoda odpowiedzialna za zbudowanie drzewa widgetów build() jest już tutaj zaimplementowana, w taki sposób, aby mieć do odpowiedniego typu providera w drzewie widgetów w konkretnym widoku. Ot zwykłe uproszczenie kodu i zwiększenie jego czytelności. Pozostałe metody służą do zarządzania cyklem życia widgetu page'a.

import 'package:flutter/material.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_base.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_factory.dart';
import 'package:provider/provider.dart';

final RouteObserver<ModalRoute> routeObserver = RouteObserver<ModalRoute>();

abstract class ParamStatefulWidget<TInitParameter> extends StatefulWidget {
  final TInitParameter _param;

  const ParamStatefulWidget(this._param, {Key? key}) : super(key: key);

  TInitParameter get param => _param;

  @override
  // ignore: no_logic_in_create_state
  State createState() => createMVVMState();

  PBMVVMPage createMVVMState();
}

abstract class PBMVVMPage<
    T extends ParamStatefulWidget,
    VM extends PBViewModelBase<TInitParameter, TVMParameter>,
    TInitParameter,
    TVMParameter extends StateData> extends State<T> with RouteAware {
  late final VM _viewModel;

  VM get viewModel => _viewModel;

  @override
  @mustCallSuper
  void initState() {
    _viewModel = context.read<ViewModelFactory>().create<VM>();
    _viewModel.initialize(widget.param);
    super.initState();
  }

  Widget buildContent(BuildContext context);

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ValueListenableProvider<TVMParameter>.value(value: viewModel),
      ],
      builder: (context, _) => buildContent(context),
    );
  }

  @override
  @mustCallSuper
  void didChangeDependencies() {
    super.didChangeDependencies();
    routeObserver.subscribe(this, ModalRoute.of(context)!);
  }

  @override
  @mustCallSuper
  void dispose() {
    routeObserver.unsubscribe(this);
    viewModel.onDispose();
    super.dispose();
  }

  @override
  @mustCallSuper
  void didPush() {}

  @override
  @mustCallSuper
  void didPopNext() {
    viewModel.onResume();
  }
}
Obecna implementacja bazowego widoku
Mam poczucie, że obecna implementacja jest lekko przekombinowana. Mam tu na myśli ilość ograniczeń w kontekście generyczności.

Chyba da się uprościć ten interfejs.

Klasa pomocnicza to zwracania instancji nowych modeli widoku. Moglibyśmy obyć się bez niej, ale wydaje się mieć sens zamknięcie jej tutaj.

abstract class ViewModelFactory {
  T create<T extends PBViewModelBase>();
}

class ViewModelFactoryImpl implements ViewModelFactory {
  @override
  T create<T extends PBViewModelBase>() => sl.get<T>();
}

I widżet GlobalProvider, którego zadaniem jest udostępnienie obiektu ViewModelFactory w drzewie widżetów. Opakujemy nim główny widżet aplikacji. Ale o tym za chwilę.

import 'package:flutter/material.dart';
import 'package:pb_flutter_mvvm_provider/di/di.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_factory.dart';
import 'package:provider/provider.dart';

class GlobalProvider extends StatelessWidget {
  const GlobalProvider({
    Key? key,
    required this.child,
  }) : super(key: key);

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider.value(value: sl.get<ViewModelFactory>()),
      ],
      child: child,
    );
  }
}

Ostatnie kroki

Do zarządzania zależnościami korzystam z biblioteki get_it. W tym celu stwórz plik di.dart i umieść w nim następujący kod:

final sl = GetIt.instance;

Future<void> initDependencies() async {
  sl.registerLazySingleton<ViewModelFactory>(() => ViewModelFactoryImpl());

  sl.registerLazySingleton<NavigationService>(
      () => NavigationService(navigatorKey));
  
  // sl.registerLazySingleton<PostsRepository>(() => PostsRepository());

  // ViewModels
  // sl.registerFactory(() => PostsViewModel(sl()));
  // sl.registerFactory(() => PostsDetailsViewModel(sl()));
}

Zakomentowany kod będzie przydatny, kiedy już zaczniemy pracę nad aplikacją demo.

import 'package:flutter/material.dart';
import 'package:pb_flutter_mvvm_provider/di/di.dart';
import 'package:pb_flutter_mvvm_provider/features/posts/posts_page.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/global_provider.dart';
import 'package:pb_flutter_mvvm_provider/mvvm/viewmodel_base.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey();
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initDependencies();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return GlobalProvider(
      child: MaterialApp(
        title: 'Flutter Demo',
          navigatorKey: navigatorKey,
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: PostsPage(NoParam())
      ),
    );
  }
}

Truskawka na torcie 🍓, czyli lista postów

Do tej pory pokazałem kod, który jest bazą rozwiązania. Czas na aplikację demo, która wykorzysta stworzone komponenty. Jak wspomniałem wcześniej, zrobimy prostą aplikację z listą postów oraz detalami wybranego. Zobaczysz w praktyce kilka przykładów testowych.

Modele

Zacznijmy od stworzenia modelu postu.  Umieść go w folderze models.

import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';

class Post extends Equatable {
  final int id;
  final String title;
  final String description;

  const Post(this.id, this.title, this.description);

  @override
  List<Object?> get props => [id, title, description];
}

class Posts extends Equatable {
  final List<Post> posts;

  const Posts(this.posts);

  @override
  List<Object?> get props => [posts];

  @override
  bool operator ==(Object other) =>
      identical(this, other) || other is List<Post> && listEquals(posts, other);
}

Kolejnym krokiem jest stworzenie klasy, która będzie reprezentować stan listy. Klasy stanu w moim podejściu korzystają z biblioteki copy_with_extension. Stąd dodatkowy atrybut.

Użycie tej biblioteki ułatwi nam aktualizację stanu, co oznacza, że wystarczy, że na obiekcie stanu wywołamy metodę copyWith(), a będziemy w stanie zaktualizować dowolną składową. Wkrótce zobaczysz to w działaniu.

part 'posts_state.g.dart';

@CopyWith()
class PostsState extends StateData {
  final Posts posts;
  final bool isLoading;

  const PostsState(this.posts, this.isLoading);

  factory PostsState.initial() => const PostsState(Posts([]), true);

  @override
  List<Object?> get props => [posts, isLoading];

  @override
  bool? get stringify => false;
}

Po zapisaniu zmian skorzystaj z komendy, która wygeneruje plik posts_state.g.dart i umożliwi nam korzystanie z dobrodziejstw copy_with.

flutter pub run build_runner build
I was freezing cold staying in the tent cabins in Half Dome (formerly Curry) Village in Yosemite Valley with my dad who was there for work, so I got up and drove around the valley at 6 am. There’s been lots of fires in the area and the valley was filled with this thick smoke. Stopped at a turnout near Tunnel View and shot this photo - I really love the symmetry of it and the smooth gradients the smoke produces.
Photo by Bailey Zindel / Unsplash

Model widoku

Przyszedł czas na dodanie repozytorium, które wykorzystamy w modelu widoku. Co prawda jego implementacja jest dość naiwna, ale nie potrzebujemy nic znacznie bardziej wyrafinowanego, aby przekazać istotę zagadnienia, więc wystarczy co mamy.

Zwróć uwagę, że mieszam dane przed ich zwróceniem w metodzie getPosts(). Przyda się nam to później, aby udowodnić, że komunikacja pomiędzy widokiem a jego modelem widoku  działa poprawnie. Czyli czy kolejność na liście ulegnie zmianie.

import 'package:pb_flutter_mvvm_provider/domain/posts.dart';

class PostsRepository {
  final _posts = [
    const Post(1, "Post 1",
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
    const Post(2, "Post 2",
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
    const Post(3, "Post 3",
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
    const Post(4, "Post 4",
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."),
  ];

  Future<Posts> getPosts() {
    _posts.shuffle();
    return Future.value(Posts(_posts));
  }

  Future<Post> getPost(int postId) {
    return Future.value(_posts.firstWhere((element) => element.id == postId));
  }
}

I teraz to na tygryski czekały najbardziej — implementacja modelu widoku na listy.  Metoda initialize() wykorzystuje kilka opóźnień, aby zasymulować zmiany na liście. Zwróć uwagę, że po około 2 sekundach lista powinna się zaktualizować, a ma to związek z logiką, którą dodaliśmy do repozytorium wcześniej.

class PostsViewModel extends ViewModelBase<NoParam, PostsState> {
  final PostsRepository _postsRepository;

  PostsViewModel(this._postsRepository) : super(PostsState.initial());

  @override
  Future<void> initialize(NoParam parameter) async {
    _showLoading();
    await Future.delayed(const Duration(milliseconds: 800));
    var posts = await _postsRepository.getPosts();
    _hideLoading();

    stateData = stateData.copyWith(posts: posts);
    await Future.delayed(const Duration(milliseconds: 2000));

    var postsUpdated = await _postsRepository.getPosts();
    stateData = stateData.copyWith(posts: postsUpdated);
    return Future.value();
  }

  Future<void> selectPost(Post selectedPost) {
    return Future.value();
  }

  void _showLoading() {
    stateData = stateData.copyWith(isLoading: true);
  }

  void _hideLoading() {
    stateData = stateData.copyWith(isLoading: false);
  }
}

To, co niesamowicie podoba mi się w tym podejściu to sposób aktualizacji obiektu stanu. Wykorzystuję silnie typowany obiekt stateData i przypisuje mu nową wartość, poprzez użycie metody rozszerzającej copyWith co oznacza, że edytuje tylko to, co powinno zostać zaktualizowane i voilà la!

Widok

Zasadniczo widok jest prosty. Korzystam z widżetu Consumer, który jest częścią biblioteki provider. Dzięki temu nasłuchujemy na wszelkie zmiany w obiekcie PostsState czyli de facto obiekt stateData.

class PostsPage extends ParamStatefulWidget<NoParam> {
  const PostsPage(super.param, {super.key});

  @override
  PBMVVMPage<ParamStatefulWidget, PBViewModelBase<dynamic, StateData>, dynamic,
      StateData> createMVVMState() {
    return _PostsPageState();
  }
}

class _PostsPageState
    extends PBMVVMPage<PostsPage, PostsViewModel, NoParam, PostsState> {
  @override
  Widget buildContent(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text("Posts"),
        ),
        body: Consumer<PostsState>(builder: (_, state, child) {
          if (state.isLoading) {
            return const Center(child: CircularProgressIndicator());
          }
          return _buildContent(state.posts.posts);
        }));
  }

  Widget _buildContent(List<Post> posts) {
    return ListView.builder(
      itemCount: posts.length,
      itemBuilder: (context, index) => _buildItem(posts[index]),
    );
  }

  Widget _buildItem(Post post) {
    final borderRadius = BorderRadius.circular(8.0);

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      child: Card(
        shape: RoundedRectangleBorder(borderRadius: borderRadius),
        elevation: 5,
        child: InkWell(
          borderRadius: borderRadius,
          onTap: () => viewModel.selectPost(post),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  post.title,
                  style: const TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                      color: Colors.black87),
                ),
                Text(
                  post.description,
                  style: const TextStyle(fontSize: 16),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Na koniec odkomentuj kod odpowiedzialny za rejestrację zależności PostsViewModel oraz PostsRepository w di.dart.

Lista postów z wykorzystaniem wzorca MVVM

Ekran detali postu

Moim zdaniem najciekawszy etap. Tym razem sprawdzisz możliwość przekazania danych do innego modelu widoku. Jak poprzednio musimy wykonać następujące kroki.

  1. Stworzyć klasę stanu i użyć biblioteki copy_with_extension.
  2. Uruchomić generator kodu.
  3. Stworzyć klasę, która będzie użyta podczas nawigacji.
  4. Stworzyć model widoku.
  5. Stworzyć widok.

Kolejne pliki stwórz w folderze post_details.

part 'posts_details_state.g.dart';

@CopyWith()
class PostsDetailsState extends StateData {
  final Post post;
  final bool isEditable;

  const PostsDetailsState(this.post, this.isEditable);

  factory PostsDetailsState.initial() =>
      const PostsDetailsState(Post(1, "", ""), false);

  @override
  List<Object?> get props => [post, isEditable];
}

W folderze navparams dodaj klasę, którą wykorzystamy do przekazania identyfikatora klikniętego postu.

class PostDetailsNavParam {
  final int postId;

  PostDetailsNavParam(this.postId);
}

class PostsDetailsViewModel
    extends ViewModelBase<PostDetailsNavParam, PostsDetailsState> {
  final PostsRepository _postsRepository;

  Post? _post;

  PostsDetailsViewModel(this._postsRepository)
      : super(PostsDetailsState.initial());

  @override
  Future<void> initialize(PostDetailsNavParam parameter) async {
    _post = await _postsRepository.getPost(parameter.postId);
    stateData = stateData.copyWith(post: _post);
  }

  void refreshPost() {
    if (_post != null) {
      _post = Post(_post!.id, _post!.title,
          "${_post!.description} \n\n<Description updated at ${DateTime.now().toIso8601String()}>");
      stateData = stateData.copyWith(post: _post);
    }
  }
}

I ostatnia składowa, widok.

class PostsDetailsPage extends ParamStatefulWidget<PostDetailsNavParam> {
  const PostsDetailsPage(super.param, {super.key});

  @override
  PBMVVMPage<ParamStatefulWidget, PBViewModelBase<dynamic, StateData>, dynamic,
      StateData> createMVVMState() => _PostsDetailsPageState();
}

class _PostsDetailsPageState extends PBMVVMPage<PostsDetailsPage,
    PostsDetailsViewModel, PostDetailsNavParam, PostsDetailsState> {
  @override
  Widget buildContent(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: const Text("Details")),
        body: Consumer<PostsDetailsState>(
            builder: (context, PostsDetailsState state, _) {
          return _buildContent(state);
        }));
  }

  Widget _buildContent(PostsDetailsState state) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
              padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
              child: Text(state.post.title,
                  style: const TextStyle(
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                      color: Colors.deepOrange))),
          Expanded(
            child: Text(
              state.post.description,
              style: const TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                  color: Colors.black87),
            ),
          ),
          Center(
            child: ElevatedButton(
                style: ElevatedButton.styleFrom(
                    minimumSize: Size(130, 50),
                    backgroundColor: Colors.green,
                    textStyle: const TextStyle(
                        fontSize: 20, fontWeight: FontWeight.bold)),
                onPressed: () => viewModel.refreshPost(),
                child: Text("Refresh")),
          )
        ],
      ),
    );
  }

  Widget _buildItem(Post post) {
    final borderRadius = BorderRadius.circular(8.0);

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      child: Card(
        shape: RoundedRectangleBorder(borderRadius: borderRadius),
        elevation: 5,
        child: InkWell(
          borderRadius: borderRadius,
          // onTap: () => viewModel.selectPost(post),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  post.title,
                  style: const TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                      color: Colors.black87),
                ),
                Text(
                  post.description,
                  style: const TextStyle(fontSize: 16),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Nie zapomnij o odkomentowaniu rejestracji ostatniego modelu widoku w pliku di.dart.

Ostatnim krokiem będzie dodanie nawigacji do nowego ekranu. Zaktualizuj metodę selectPost() w PostsViewModel.

 Future<void> selectPost(Post selectedPost) async {
    await navigationService
        .navigateToPage(PostsDetailsPage(PostDetailsNavParam(selectedPost.id)));
  }

Na tym koniec

Poniżej wizualne demo naszej pracy. Daj, proszę, znać co myślisz o mojej propozycji implementacji  Flutter MVVM. Widzisz jakieś dziury, niedociągnięcia? Czy może jednak wygląda to tak dobrze, że nie bałbyś się użyć tego w aplikacji produkcyjnej?

Dodaj komentarz z opinią albo po prostu odezwij się na kontakt@programistabyc.pl.

Link do repozytorium ze zrealizowanym projektem:

GitHub - krzbbaranowski/flutter_mvvm_with_provider_sample
Contribute to krzbbaranowski/flutter_mvvm_with_provider_sample development by creating an account on GitHub.