diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000000000000000000000000000000000000..ccdef33ac22d7d8582b9320bf670d969d3833f10 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,105 @@ +import 'package:article_flutter_riverpod/example.dart'; +import 'package:article_flutter_riverpod/screens/example_group_screen.dart'; +import 'package:article_flutter_riverpod/screens/example_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root'); +final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'shell'); + +class App extends StatelessWidget { + App( + this.groups, { + Key? key, + }) : router = buildRouter(groups), + super(key: key); + + final List<ExampleGroup> groups; + final GoRouter router; + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Flutter par la pratique', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + routeInformationProvider: router.routeInformationProvider, + routerDelegate: router.routerDelegate, + routeInformationParser: router.routeInformationParser, + ); + } + + static GoRouter buildRouter(List<ExampleGroup> groups) { + return GoRouter( + navigatorKey: _rootNavigatorKey, + initialLocation: groups[0].path, + routes: [ + ShellRoute( + navigatorKey: _shellNavigatorKey, + builder: (_, __, child) => ScaffoldWithNavBar(groups, child: child), + routes: [ + for (ExampleGroup group in groups) // + GoRoute( + path: group.path, + builder: (_, __) => ExampleGroupScreen(group), + routes: [ + for (Example example in group.examples) // + GoRoute( + path: example.path, + builder: (_, __) => ExampleScreen(example), + ), + ], + ), + ], + ) + ], + ); + } +} + +class ScaffoldWithNavBar extends StatelessWidget { + const ScaffoldWithNavBar( + this.groups, { + required this.child, + Key? key, + }) : super(key: key); + + final List<ExampleGroup> groups; + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: child, + bottomNavigationBar: BottomNavigationBar( + items: [ + for (ExampleGroup group in groups) // + BottomNavigationBarItem( + icon: Icon(group.icon), + label: group.title, + ), + ], + currentIndex: _calculateSelectedIndex(context), + onTap: (index) => _onItemTapped(index, context), + ), + ); + } + + int _calculateSelectedIndex(BuildContext context) { + final GoRouter route = GoRouter.of(context); + final String location = route.location; + + for (var i = 0; i < groups.length; i++) { + if (location.startsWith(groups[i].path)) { + return i; + } + } + + return 0; + } + + void _onItemTapped(int index, BuildContext context) { + context.go(groups[index].path); + } +} diff --git a/lib/example.dart b/lib/example.dart index 0e6c382f59da3c13b8b45348bb0cfb1be2ebd950..bff1680ce10baa1826baa85d3c07b400ac06eb69 100644 --- a/lib/example.dart +++ b/lib/example.dart @@ -3,10 +3,14 @@ import 'package:flutter/material.dart'; class ExampleGroup { const ExampleGroup({ required this.title, + required this.icon, + required this.path, required this.examples, }); final String title; + final IconData icon; + final String path; final List<Example> examples; } @@ -25,28 +29,3 @@ class Example { final WidgetBuilder builder; final bool isScrollable; } - -class ExampleContainer extends StatelessWidget { - const ExampleContainer( - this.example, { - Key? key, - }) : super(key: key); - - final Example example; - - @override - Widget build(BuildContext context) { - final child = example.builder(context); - - return Scaffold( - appBar: AppBar( - title: Text(example.title), - ), - body: Center( - child: example.isScrollable - ? SingleChildScrollView(child: child) // - : child, - ), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index 68b8034592bb91285379efd4b1cbea77f555dfe7..438938f7de20f7392c00ab31344aa69cb4a87495 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:article_flutter_riverpod/app.dart'; import 'package:article_flutter_riverpod/article_02/consumer_example.dart'; import 'package:article_flutter_riverpod/article_02/parent_example.dart'; import 'package:article_flutter_riverpod/article_02/stateful_example.dart'; @@ -12,12 +13,12 @@ import 'package:article_flutter_riverpod/presentation/07_overrides_example.dart' import 'package:article_flutter_riverpod/presentation/08_future_provider_example.dart' as future_provider_example; import 'package:article_flutter_riverpod/presentation/09_async_value_example.dart' as async_value_example; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sticky_headers/sticky_headers.dart'; -final examples = [ +final _groups = [ ExampleGroup( title: 'Article 01', + icon: Icons.filter_1, + path: '/article_01', examples: [ Example( title: 'Consumer', @@ -47,6 +48,8 @@ final examples = [ ), ExampleGroup( title: 'Presentation', + icon: Icons.screenshot_monitor, + path: '/presentation', examples: [ Example( title: 'Introduction', @@ -109,106 +112,5 @@ final examples = [ ]; void main() { - runApp(ArticleApp()); -} - -class ArticleApp extends StatelessWidget { - ArticleApp({Key? key}) : super(key: key); - - final _router = GoRouter(routes: [ - GoRoute( - path: '/', - builder: (_, __) => const HomePage(), - routes: [ - for (ExampleGroup exampleTitle in examples) // - for (Example example in exampleTitle.examples) // - GoRoute( - path: example.path, - builder: (_, __) => ExampleContainer(example), - ) - ], - ), - ]); - - @override - Widget build(BuildContext context) { - return MaterialApp.router( - routeInformationProvider: _router.routeInformationProvider, - routeInformationParser: _router.routeInformationParser, - routerDelegate: _router.routerDelegate, - title: 'Flutter par la pratique', - theme: ThemeData( - primarySwatch: Colors.blue, - ), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Flutter par la pratique'), - ), - body: ListView.builder( - itemBuilder: (_, index) => ExampleGroupListItem(examples[index]), - itemCount: examples.length, - ), - ); - } -} - -class ExampleGroupListItem extends StatelessWidget { - const ExampleGroupListItem(this.exampleGroup, {Key? key}) : super(key: key); - - final ExampleGroup exampleGroup; - - @override - Widget build(BuildContext context) { - return StickyHeader( - header: Container( - height: 50, - color: Colors.white, - alignment: Alignment.centerLeft, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - exampleGroup.title, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.subtitle2, - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (Example example in exampleGroup.examples) // - ExampleListItem(example) - ], - ), - ); - } -} - -class ExampleListItem extends StatelessWidget { - const ExampleListItem(this.example, {Key? key}) : super(key: key); - - final Example example; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon( - example.icon, - color: Theme.of(context).primaryColor, - ), - title: Text( - example.title, - overflow: TextOverflow.ellipsis, - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push('/${example.path}'), - ); - } + runApp(App(_groups)); } diff --git a/lib/presentation/05_state_provider_example.dart b/lib/presentation/05_state_provider_example.dart index efd9cf706acac320786e1c7cdea8fc20cd52a111..725bcfcb42663ce08be05ef9cb2b4b994518c9f1 100644 --- a/lib/presentation/05_state_provider_example.dart +++ b/lib/presentation/05_state_provider_example.dart @@ -52,7 +52,9 @@ class _TodoListState extends ConsumerState<TodoList> { void _onCheckedChanged(Todo todo, bool? value) { final updatedTodo = todo.copyWith(checked: value != null && value); - ref.read(todosProvider.notifier).state = ref.read(todosProvider.notifier).state.copyWithTodo(updatedTodo); + + final todos = ref.read(todosProvider); + ref.read(todosProvider.notifier).state = todos.copyWithTodo(updatedTodo); } } diff --git a/lib/presentation/09_async_value_example.dart b/lib/presentation/09_async_value_example.dart index 3254c29da375ff9281f3241f677a405a7a0ab32f..88600b753de9644dc850fa5877263116c7f7edd7 100644 --- a/lib/presentation/09_async_value_example.dart +++ b/lib/presentation/09_async_value_example.dart @@ -1,3 +1,4 @@ +import 'package:article_flutter_riverpod/presentation/01_introduction_example.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -160,12 +161,19 @@ class TodosStateNotifier extends StateNotifier<AsyncValue<List<Todo>>> { final TodosRepository todosRepository; void load() async { - state = await AsyncValue.guard(() => todosRepository.getTodos()); + final todos = await AsyncValue.guard(() => todosRepository.getTodos()); + if (mounted) { + state = todos; + } } void updateTodo(Todo updatedTodo) async { state = const AsyncValue.loading(); - state = await AsyncValue.guard(() => todosRepository.updateTodos(updatedTodo)); + + final todos = await AsyncValue.guard(() => todosRepository.updateTodos(updatedTodo)); + if (mounted) { + state = todos; + } } } diff --git a/lib/screens/example_group_screen.dart b/lib/screens/example_group_screen.dart new file mode 100644 index 0000000000000000000000000000000000000000..c65917d8022f7765ecf06b292e81a2077ea59f97 --- /dev/null +++ b/lib/screens/example_group_screen.dart @@ -0,0 +1,59 @@ +import 'package:article_flutter_riverpod/example.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ExampleGroupScreen extends StatelessWidget { + const ExampleGroupScreen( + this.group, { + Key? key, + }) : super(key: key); + + final ExampleGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(group.title), + ), + body: ListView.builder( + itemBuilder: (_, index) => ExampleListItem( + group, + group.examples[index], + ), + itemCount: group.examples.length, + ), + ); + } +} + +class ExampleListItem extends StatelessWidget { + const ExampleListItem( + this.group, + this.example, { + Key? key, + }) : super(key: key); + + final ExampleGroup group; + final Example example; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon( + example.icon, + color: Theme.of(context).primaryColor, + ), + title: Text( + example.title, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _onItemTapped(context, example), + ); + } + + void _onItemTapped(BuildContext context, Example example) { + context.go("${group.path}/${example.path}"); + } +} diff --git a/lib/screens/example_screen.dart b/lib/screens/example_screen.dart new file mode 100644 index 0000000000000000000000000000000000000000..1c73a96812733c077988e7724a8358b8b7f4c125 --- /dev/null +++ b/lib/screens/example_screen.dart @@ -0,0 +1,27 @@ +import 'package:article_flutter_riverpod/example.dart'; +import 'package:flutter/material.dart'; + +class ExampleScreen extends StatelessWidget { + const ExampleScreen( + this.example, { + Key? key, + }) : super(key: key); + + final Example example; + + @override + Widget build(BuildContext context) { + final child = example.builder(context); + + return Scaffold( + appBar: AppBar( + title: Text(example.title), + ), + body: Center( + child: example.isScrollable + ? SingleChildScrollView(child: child) // + : child, + ), + ); + } +} diff --git a/lib/toto.md b/lib/toto.md index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..7c0e3fd76e05e99cc1f5f289afb64b45d8aa2aaf 100644 --- a/lib/toto.md +++ b/lib/toto.md @@ -0,0 +1,177 @@ +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class TodoExample extends StatelessWidget { + const TodoExample({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const ProviderScope( + child: TodoList(), + ); + } +} + +class TodoList extends ConsumerStatefulWidget { + const TodoList({Key? key}) : super(key: key); + + @override + ConsumerState<TodoList> createState() => _TodoListState(); +} + +class _TodoListState extends ConsumerState<TodoList> { + bool _isUncheckedFilter = false; + + @override + Widget build(BuildContext context) { + final todos = ref.watch(todosProvider.select((tds) => tds.whereTodos(_isUncheckedFilter))); + + return Column( + children: [ + TodoListFilter( + filter: _isUncheckedFilter, + onFilterChanged: _onFilterChanged, + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemBuilder: (_, index) => + TodoListItem( + todos[index], + onCheckedChanged: (value) => _onCheckedChanged(todos[index], value), + ), + itemCount: todos.length, + ), + ), + ], + ); + } + + void _onFilterChanged(bool value) { + setState(() => _isUncheckedFilter = value); + } + + void _onCheckedChanged(Todo todo, bool? value) { + final updatedTodo = todo.copyWith(checked: value != null && value); + + final todos = ref.read(todosProvider.notifier).state; + ref.read(todosProvider.notifier).state = todos.copyWithTodo(updatedTodo); + } +} + +class TodoListFilter extends StatelessWidget { + const TodoListFilter({ + Key? key, + required this.filter, + required this.onFilterChanged, + }) : super(key: key); + + final bool filter; + final ValueChanged<bool> onFilterChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Unchecked"), + Switch( + value: filter, + onChanged: onFilterChanged, + ), + ], + ), + ); + } +} + +class TodoListItem extends StatelessWidget { + const TodoListItem(this.todo, { + Key? key, + this.onCheckedChanged, + }) : super(key: key); + + final Todo todo; + final ValueChanged<bool?>? onCheckedChanged; + + @override + Widget build(BuildContext context) { + return CheckboxListTile( + value: todo.checked, + onChanged: onCheckedChanged, + title: Text( + todo.title, + overflow: TextOverflow.ellipsis, + ), + controlAffinity: ListTileControlAffinity.leading, + ); + } +} + +final todosProvider = StateProvider<List<Todo>>((ref) => todos); + +const todos = [ + Todo( + id: 0, + title: 'Unit test passed', + checked: true, + ), + Todo( + id: 1, + title: 'Code reviewed', + checked: true, + ), + Todo( + id: 2, + title: 'Acceptance criteria for each issue met', + ), + Todo( + id: 3, + title: 'Functional tests passed', + ), + Todo( + id: 4, + title: 'Non-functional requirements met', + ), + Todo( + id: 5, + title: 'Product owner accepts the User Story', + ), +]; + +class Todo { + const Todo({ + required this.id, + required this.title, + this.checked = false, + }); + + final int id; + final String title; + final bool checked; + + Todo copyWith({ + int? id, + String? title, + bool? checked, + }) { + return Todo( + id: id ?? this.id, + title: title ?? this.title, + checked: checked ?? this.checked, + ); + } +} + +extension TodoListExtension on List<Todo> { + List<Todo> whereTodos(bool isUnchecked) => // + where((todo) => !isUnchecked || !todo.checked).toList(); + + List<Todo> copyWithTodo(Todo updatedTodo) => // + [for (Todo todo in this) todo.id == updatedTodo.id ? updatedTodo : todo]; +} + +``` \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 2e4e015be1eda5adacb447482a40a661b01dfe9a..dbdf552e195ad1ccf803eb43c122d1073c41ec24 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -92,7 +92,7 @@ packages: name: go_router url: "https://pub.dartlang.org" source: hosted - version: "4.3.0" + version: "4.5.0" js: dependency: transitive description: @@ -175,13 +175,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.7.2+1" - sticky_headers: - dependency: "direct main" - description: - name: sticky_headers - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0+2" stream_channel: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3437f3bd69f7b90b41159989c85691004e3499f6..3cfd5b0b6789adf0781ce1b2bebf2a6c47cab44f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,8 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 flutter_riverpod: ^2.0.0-dev.9 - go_router: ^4.3.0 - sticky_headers: ^0.3.0 + go_router: ^4.5.0 dev_dependencies: flutter_test: