Skip to content

Commit a2dcecd

Browse files
Merge pull request #55 from nventive/dev/jpl/mvvm
feat: Replace Riverpod with custom ViewModels.
2 parents 70e924c + 7e78fa9 commit a2dcecd

18 files changed

Lines changed: 518 additions & 138 deletions

doc/Architecture.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,41 @@ See [Navigation.md](Navigation.md) for more details.
114114

115115
### State Management
116116

117-
This application uses [Riverpod](https://pub.dev/packages/riverpod) to implement the MVVM pattern. The `ViewModel` class is used as a base class for all ViewModels.
117+
This application uses the MVVM pattern. The `ViewModel` class is used as a base class for all ViewModels.
118+
ViewModels have the concept of _dynamic properties_.
119+
These defined using accessors that call the `get` (or variants such as `getLazy`) and optionally `set` methods to automatically trigger widget rebuilds.
120+
121+
Here is an example of a ViewModel showcasing the usage of dynamic properties.
122+
```dart
123+
class HomePageViewModel extends ViewModel {
124+
// Regular property don't trigger rebuild if changed.
125+
final String title = 'Flutter Demo Home Page';
126+
127+
// Dynamic properties triggers rebuild if changed.
128+
int get counter => get('counter', 0);
129+
set counter(int value) => set('counter', value);
130+
131+
List<HomeItemViewModel> get items => getLazy(
132+
'items',
133+
() => [
134+
HomeItemViewModel('1'),
135+
HomeItemViewModel('2'),
136+
HomeItemViewModel('3'),
137+
]);
138+
139+
Future<int> get someData => getLazy('someData', _loadSomeData);
140+
set someData(Future<int> value) => set('someData', value);
141+
142+
Future<int> _loadSomeData() async {
143+
await Future.delayed(const Duration(seconds: 2));
144+
return 42;
145+
}
146+
147+
void reloadSomeData() {
148+
someData = _loadSomeData();
149+
}
150+
}
151+
```
118152

119153
### UI Framework
120154

src/app/analysis_options.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ include: package:flutter_lints/flutter.yaml
1212
analyzer:
1313
exclude:
1414
- '**.g.dart'
15+
plugins:
16+
- custom_lint
1517

1618
linter:
1719
# The lint rules applied to this project can be customized in the

src/app/lib/app.dart

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
import 'package:app/app_router.dart';
22
import 'package:app/l10n/gen_l10n/app_localizations.dart';
33
import 'package:flutter/material.dart';
4-
import 'package:flutter_riverpod/flutter_riverpod.dart';
54

65
final class App extends StatelessWidget {
76
const App({super.key});
87

98
@override
109
Widget build(BuildContext context) {
11-
return ProviderScope(
12-
child: MaterialApp.router(
13-
routerConfig: router,
14-
localizationsDelegates: AppLocalizations.localizationsDelegates,
15-
supportedLocales: AppLocalizations.supportedLocales,
16-
),
10+
return MaterialApp.router(
11+
routerConfig: router,
12+
localizationsDelegates: AppLocalizations.localizationsDelegates,
13+
supportedLocales: AppLocalizations.supportedLocales,
1714
);
1815
}
1916
}

src/app/lib/presentation/dad_jokes/dad_joke_list_item.dart

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import 'package:app/business/dad_jokes/dad_joke.dart';
2-
import 'package:app/business/dad_jokes/dad_jokes_service.dart';
32
import 'package:flutter/material.dart';
4-
import 'package:get_it/get_it.dart';
53

64
/// A dad joke list item.
75
final class DadJokeListItem extends StatelessWidget {
8-
/// The dad jokes service used to add or remove favorite.
9-
final _dadJokesService = GetIt.I<DadJokesService>();
10-
116
/// The dad joke.
127
final DadJoke dadJoke;
8+
final Future<void> Function(DadJoke dadJoke) toggleIsFavorite;
139

14-
DadJokeListItem({super.key, required this.dadJoke});
10+
const DadJokeListItem({super.key, required this.dadJoke, required this.toggleIsFavorite});
1511

1612
@override
1713
Widget build(BuildContext context) {
@@ -29,13 +25,7 @@ final class DadJokeListItem extends StatelessWidget {
2925
),
3026
titleAlignment: ListTileTitleAlignment.top,
3127
contentPadding: const EdgeInsets.all(16),
32-
onTap: () async {
33-
if (dadJoke.isFavorite) {
34-
await _dadJokesService.removeFavoriteDadJoke(dadJoke);
35-
} else {
36-
await _dadJokesService.addFavoriteDadJoke(dadJoke);
37-
}
38-
},
28+
onTap: () => toggleIsFavorite(dadJoke),
3929
),
4030
);
4131
}
Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,52 @@
1-
import 'package:app/business/dad_jokes/dad_joke.dart';
2-
import 'package:app/business/dad_jokes/dad_jokes_service.dart';
31
import 'package:app/l10n/localization_extensions.dart';
42
import 'package:app/presentation/dad_jokes/dad_joke_list_item.dart';
3+
import 'package:app/presentation/dad_jokes/dad_jokes_page_viewmodel.dart';
4+
import 'package:app/presentation/mvvm/mvvm_widget.dart';
55
import 'package:flutter/material.dart';
6-
import 'package:flutter_riverpod/flutter_riverpod.dart';
7-
import 'package:get_it/get_it.dart';
86

97
/// The dad jokes page.
10-
final class DadJokesPage extends ConsumerWidget {
11-
/// Provider that provides dad jokes.
12-
static final _dadJokesProvider = StreamProvider<List<DadJoke>>((ref) async* {
13-
final dadJokesService = GetIt.I<DadJokesService>();
14-
15-
await for (final dadJokes in dadJokesService.dadJokesStream) {
16-
yield dadJokes;
17-
}
18-
});
19-
8+
final class DadJokesPage extends MvvmWidget<DadJokesPageViewModel> {
209
const DadJokesPage({super.key});
2110

2211
@override
23-
Widget build(BuildContext context, WidgetRef ref) {
24-
final dadJokesAsyncValue = ref.watch(_dadJokesProvider);
12+
DadJokesPageViewModel getViewModel() {
13+
return DadJokesPageViewModel();
14+
}
15+
16+
@override
17+
Widget build(BuildContext context, DadJokesPageViewModel viewModel) {
2518
final local = context.local;
2619
return Scaffold(
2720
appBar: AppBar(
2821
title: Text(local.dadJokesPageTitle),
2922
),
30-
body: dadJokesAsyncValue.when(
31-
data: (dadJokes) {
32-
return Container(
33-
key: const Key('DadJokesContainer'),
34-
child: ListView.builder(
35-
itemCount: dadJokes.length,
36-
itemBuilder: (context, index) {
37-
final dadJoke = dadJokes[index];
38-
return DadJokeListItem(
39-
dadJoke: dadJoke,
40-
);
41-
},
42-
),
43-
);
44-
},
45-
loading: () => const Center(
46-
child: CircularProgressIndicator(),
47-
),
48-
error: (error, stackTrace) => Text(
49-
local.error(error),
50-
),
51-
),
23+
body:
24+
StreamBuilder(stream: viewModel.dadJokesStream, builder: (context, snapshot) {
25+
if (snapshot.hasData) {
26+
final dadJokes = snapshot.data;
27+
return Container(
28+
key: const Key('DadJokesContainer'),
29+
child: ListView.builder(
30+
itemCount: dadJokes?.length ?? 0,
31+
itemBuilder: (context, index) {
32+
final dadJoke = dadJokes![index];
33+
return DadJokeListItem(
34+
dadJoke: dadJoke,
35+
toggleIsFavorite: viewModel.toggleIsFavorite,
36+
);
37+
},
38+
),
39+
);
40+
} else if (snapshot.hasError) {
41+
return Text(
42+
local.error(snapshot.error!),
43+
);
44+
} else {
45+
return const Center(
46+
child: CircularProgressIndicator(),
47+
);
48+
}
49+
}),
5250
);
5351
}
5452
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import 'package:app/business/dad_jokes/dad_joke.dart';
2+
import 'package:app/business/dad_jokes/dad_jokes_service.dart';
3+
import 'package:app/presentation/mvvm/view_model.dart';
4+
import 'package:get_it/get_it.dart';
5+
6+
class DadJokesPageViewModel extends ViewModel {
7+
final _dadJokesService = GetIt.I<DadJokesService>();
8+
9+
Stream<List<DadJoke>> get dadJokesStream =>
10+
getLazy("dadJokesStream", () => _dadJokesService.dadJokesStream);
11+
12+
Future<void> toggleIsFavorite(DadJoke dadJoke) async {
13+
if (dadJoke.isFavorite) {
14+
await _dadJokesService.removeFavoriteDadJoke(dadJoke);
15+
} else {
16+
await _dadJokesService.addFavoriteDadJoke(dadJoke);
17+
}
18+
}
19+
}
Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,53 @@
1-
import 'package:app/business/dad_jokes/dad_joke.dart';
2-
import 'package:app/business/dad_jokes/dad_jokes_service.dart';
31
import 'package:app/l10n/localization_extensions.dart';
42
import 'package:app/presentation/dad_jokes/dad_joke_list_item.dart';
3+
import 'package:app/presentation/dad_jokes/favorite_dad_jokes_viewmodel.dart';
4+
import 'package:app/presentation/mvvm/mvvm_widget.dart';
55
import 'package:flutter/material.dart';
6-
import 'package:flutter_riverpod/flutter_riverpod.dart';
7-
import 'package:get_it/get_it.dart';
86

97
/// The favorite dad jokes page.
10-
final class FavoriteDadJokesPage extends ConsumerWidget {
11-
/// Provider that provides favorite dad jokes.
12-
static final _dadJokesProvider = StreamProvider<List<DadJoke>>((ref) async* {
13-
final dadJokesService = GetIt.I<DadJokesService>();
14-
15-
await for (final dadJokes in dadJokesService.dadJokesStream) {
16-
yield dadJokes.where((dadJoke) => dadJoke.isFavorite).toList();
17-
}
18-
});
19-
8+
final class FavoriteDadJokesPage extends MvvmWidget<FavoriteDadJokesViewModel> {
209
const FavoriteDadJokesPage({super.key});
2110

2211
@override
23-
Widget build(BuildContext context, WidgetRef ref) {
24-
final dadJokesAsyncValue = ref.watch(_dadJokesProvider);
12+
FavoriteDadJokesViewModel getViewModel() {
13+
return FavoriteDadJokesViewModel();
14+
}
15+
16+
@override
17+
Widget build(BuildContext context, FavoriteDadJokesViewModel viewModel) {
2518
final local = context.local;
2619
return Scaffold(
2720
appBar: AppBar(
2821
title: Text(local.favoriteDadJokesPageTitle),
2922
),
30-
body: dadJokesAsyncValue.when(
31-
data: (dadJokes) {
32-
return Container(
33-
key: const Key("FavoriteJokesContainer"),
34-
child: ListView.builder(
35-
itemCount: dadJokes.length,
36-
itemBuilder: (context, index) {
37-
final dadJoke = dadJokes[index];
38-
return DadJokeListItem(
39-
dadJoke: dadJoke,
40-
);
41-
},
42-
),
43-
);
44-
},
45-
loading: () => const Center(
46-
child: CircularProgressIndicator(),
47-
),
48-
error: (error, stackTrace) => Text(local.error(error)),
49-
),
23+
body: StreamBuilder(
24+
stream: viewModel.favorites,
25+
builder: (context, snapshot) {
26+
if (snapshot.hasData) {
27+
final dadJokes = snapshot.data;
28+
return Container(
29+
key: const Key("FavoriteJokesContainer"),
30+
child: ListView.builder(
31+
itemCount: dadJokes?.length ?? 0,
32+
itemBuilder: (context, index) {
33+
final dadJoke = dadJokes![index];
34+
return DadJokeListItem(
35+
dadJoke: dadJoke,
36+
toggleIsFavorite: viewModel.toggleIsFavorite,
37+
);
38+
},
39+
),
40+
);
41+
} else if (snapshot.hasError) {
42+
return Text(
43+
local.error(snapshot.error!),
44+
);
45+
} else {
46+
return const Center(
47+
child: CircularProgressIndicator(),
48+
);
49+
}
50+
}),
5051
);
5152
}
5253
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'package:app/business/dad_jokes/dad_joke.dart';
2+
import 'package:app/business/dad_jokes/dad_jokes_service.dart';
3+
import 'package:app/presentation/mvvm/view_model.dart';
4+
import 'package:get_it/get_it.dart';
5+
6+
class FavoriteDadJokesViewModel extends ViewModel {
7+
final _dadJokesService = GetIt.I<DadJokesService>();
8+
9+
Stream<List<DadJoke>> get favorites => getLazy(
10+
"favorites",
11+
() => _dadJokesService.dadJokesStream.map((List<DadJoke> jokes) =>
12+
jokes.where((DadJoke joke) => joke.isFavorite).toList()));
13+
14+
Future<void> toggleIsFavorite(DadJoke dadJoke) async {
15+
if (dadJoke.isFavorite) {
16+
await _dadJokesService.removeFavoriteDadJoke(dadJoke);
17+
} else {
18+
await _dadJokesService.addFavoriteDadJoke(dadJoke);
19+
}
20+
}
21+
}

0 commit comments

Comments
 (0)