From 1e3a74144b7a0ff502748f21cc54388641c8012c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 20 Dec 2023 18:51:58 -0700 Subject: [PATCH] feat: add services and todo feature --- .github/workflows/ci.yml | 8 ++ Makefile | 36 ++++++- lib/features/app/app.dart | 4 +- lib/features/app/app_tabs.dart | 57 +++++++++++ lib/features/counter/counter_page.dart | 37 +++++++ lib/features/home/home_page.dart | 39 -------- lib/features/todos/todo_form_page.dart | 98 +++++++++++++++++++ lib/features/todos/todos_notifier.dart | 31 ++++++ lib/features/todos/todos_page.dart | 65 ++++++++++++ lib/l10n/app_en.arb | 11 +++ lib/l10n/app_localizations.dart | 54 ++++++++++ lib/l10n/app_localizations_en.dart | 27 +++++ packages/models/.gitignore | 7 ++ packages/models/CHANGELOG.md | 3 + packages/models/README.md | 3 + packages/models/analysis_options.yaml | 4 + packages/models/lib/models.dart | 3 + packages/models/lib/src/models/todo.dart | 53 ++++++++++ packages/models/pubspec.yaml | 14 +++ packages/models/test/models/todo_test.dart | 35 +++++++ packages/services/.gitignore | 7 ++ packages/services/CHANGELOG.md | 3 + packages/services/README.md | 3 + packages/services/analysis_options.yaml | 4 + packages/services/lib/services.dart | 4 + packages/services/lib/src/providers.dart | 6 ++ .../lib/src/services/todos_service.dart | 18 ++++ packages/services/pubspec.yaml | 17 ++++ .../test/services/todos_service_test.dart | 59 +++++++++++ pubspec.lock | 48 ++++++++- pubspec.yaml | 5 + test/helpers/widget_helpers.dart | 26 +++++ test/widget_test.dart | 25 ++--- 33 files changed, 753 insertions(+), 61 deletions(-) create mode 100644 lib/features/app/app_tabs.dart create mode 100644 lib/features/counter/counter_page.dart delete mode 100644 lib/features/home/home_page.dart create mode 100644 lib/features/todos/todo_form_page.dart create mode 100644 lib/features/todos/todos_notifier.dart create mode 100644 lib/features/todos/todos_page.dart create mode 100644 packages/models/.gitignore create mode 100644 packages/models/CHANGELOG.md create mode 100644 packages/models/README.md create mode 100644 packages/models/analysis_options.yaml create mode 100644 packages/models/lib/models.dart create mode 100644 packages/models/lib/src/models/todo.dart create mode 100644 packages/models/pubspec.yaml create mode 100644 packages/models/test/models/todo_test.dart create mode 100644 packages/services/.gitignore create mode 100644 packages/services/CHANGELOG.md create mode 100644 packages/services/README.md create mode 100644 packages/services/analysis_options.yaml create mode 100644 packages/services/lib/services.dart create mode 100644 packages/services/lib/src/providers.dart create mode 100644 packages/services/lib/src/services/todos_service.dart create mode 100644 packages/services/pubspec.yaml create mode 100644 packages/services/test/services/todos_service_test.dart create mode 100644 test/helpers/widget_helpers.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e5d09..b49e494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,14 @@ jobs: - name: Init Hermit uses: cashapp/activate-hermit@v1 + - name: Cache Flutter + uses: actions/cache@v2 + with: + path: ~/.cache/hermit/cache/pkg + key: ${{ runner.os }}-hermit-cache-${{ hashFiles('bin/.*.pkg') }} + restore-keys: | + ${{ runner.os }}-hermit-cache- + - name: Install Dependencies run: make get diff --git a/Makefile b/Makefile index cbe1001..e8c6f43 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,16 @@ run: .PHONY: get get: - flutter pub get + @echo "Getting dependencies for main project" + @flutter pub get + @echo "Getting dependencies for packages" + @for dir in packages/*; do \ + if [ -d $$dir ]; then \ + echo "Getting dependencies in $$dir"; \ + (cd $$dir && flutter pub get || dart pub get); \ + fi \ + done + .PHONY: clean clean: @@ -18,12 +27,31 @@ build: flutter build apk .PHONY: test -test: - flutter test +test: test-app test-packages + +.PHONY: test-app +test-app: + @echo "Running Flutter tests" + @flutter test + +.PHONY: test-packages +test-packages: + @echo "Running Dart tests in packages" + @for dir in packages/*; do \ + if [ -d $$dir ]; then \ + echo "Running tests in $$dir"; \ + (cd $$dir && dart test); \ + fi \ + done .PHONY: analyze analyze: - flutter analyze + @flutter analyze + @for dir in packages/*; do \ + if [ -d $$dir ]; then \ + (cd $$dir && dart analyze); \ + fi \ + done .PHONY: generate generate: diff --git a/lib/features/app/app.dart b/lib/features/app/app.dart index 4dca0d6..d3ef383 100644 --- a/lib/features/app/app.dart +++ b/lib/features/app/app.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_starter/features/home/home_page.dart'; +import 'package:flutter_starter/features/app/app_tabs.dart'; import 'package:flutter_starter/l10n/app_localizations.dart'; import 'package:flutter_starter/shared/theme/theme.dart'; @@ -12,7 +12,7 @@ class App extends StatelessWidget { title: 'Flutter Starter App', theme: lightTheme(context), darkTheme: darkTheme(context), - home: const HomePage(), + home: const AppTabs(), localizationsDelegates: Loc.localizationsDelegates, supportedLocales: const [ Locale('en', ''), diff --git a/lib/features/app/app_tabs.dart b/lib/features/app/app_tabs.dart new file mode 100644 index 0000000..98681c6 --- /dev/null +++ b/lib/features/app/app_tabs.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_starter/features/counter/counter_page.dart'; +import 'package:flutter_starter/features/todos/todos_page.dart'; +import 'package:flutter_starter/l10n/app_localizations.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _TabItem { + final String label; + final Icon icon; + final Widget screen; + + _TabItem(this.label, this.icon, this.screen); +} + +class AppTabs extends HookConsumerWidget { + const AppTabs({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedIndex = useState(0); + + final tabs = [ + _TabItem( + Loc.of(context).counter, + const Icon(Icons.numbers), + const CounterPage(), + ), + _TabItem( + Loc.of(context).todos, + const Icon(Icons.check), + const TodosPage(), + ), + ]; + + return Scaffold( + body: IndexedStack( + index: selectedIndex.value, + children: tabs.map((tab) => tab.screen).toList(), + ), + bottomNavigationBar: BottomNavigationBar( + fixedColor: Theme.of(context).colorScheme.primary, + selectedFontSize: 12, + currentIndex: selectedIndex.value, + onTap: (index) => selectedIndex.value = index, + items: tabs + .map( + (tab) => BottomNavigationBarItem( + icon: tab.icon, + label: tab.label, + ), + ) + .toList(), + ), + ); + } +} diff --git a/lib/features/counter/counter_page.dart b/lib/features/counter/counter_page.dart new file mode 100644 index 0000000..72b41e3 --- /dev/null +++ b/lib/features/counter/counter_page.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_starter/l10n/app_localizations.dart'; + +class CounterPage extends HookWidget { + const CounterPage({super.key}); + + @override + Widget build(BuildContext context) { + final counter = useState(0); + + return Scaffold( + appBar: AppBar(title: Text(Loc.of(context).appName)), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + Loc.of(context).youHavePushedTheButton, + textAlign: TextAlign.center, + ), + Text( + '${counter.value}', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + heroTag: 'counter', + onPressed: () => counter.value++, + tooltip: Loc.of(context).increment, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart deleted file mode 100644 index c978436..0000000 --- a/lib/features/home/home_page.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_starter/l10n/app_localizations.dart'; - -class HomePage extends HookWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - final counter = useState(0); - - return Scaffold( - appBar: AppBar(title: Text(Loc.of(context).appName)), - body: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - Loc.of(context).youHavePushedTheButton, - textAlign: TextAlign.center, - ), - Text( - '${counter.value}', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: () => counter.value++, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), - ); - } -} diff --git a/lib/features/todos/todo_form_page.dart b/lib/features/todos/todo_form_page.dart new file mode 100644 index 0000000..87b1593 --- /dev/null +++ b/lib/features/todos/todo_form_page.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_starter/features/todos/todos_notifier.dart'; +import 'package:flutter_starter/l10n/app_localizations.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; + +class TodoFormPage extends HookConsumerWidget { + final Todo? todo; + final bool isNew; + + const TodoFormPage({this.todo, super.key}) : isNew = todo == null; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final title = useState(todo?.title); + final description = useState(todo?.description); + + return Scaffold( + appBar: AppBar(title: Text(isNew ? 'New Todo' : 'Edit Todo')), + body: Form( + child: Padding( + padding: const EdgeInsets.all(16), + child: Stack( + children: [ + SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + TextFormField( + autofocus: true, + initialValue: title.value, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.next, + onChanged: (value) => title.value = value, + decoration: InputDecoration( + labelText: Loc.of(context).title, + ), + ), + TextFormField( + initialValue: description.value, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.done, + onChanged: (value) => description.value = value, + decoration: InputDecoration( + labelText: Loc.of(context).description, + ), + maxLines: null, + ), + ], + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: double.infinity, + child: FilledButton( + child: Text(Loc.of(context).save), + onPressed: () => + _save(context, ref, title.value, description.value), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _save( + BuildContext context, WidgetRef ref, String? title, String? description) { + if (title == null || title.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(Loc.of(context).titleRequired)), + ); + return; + } + + if (isNew) { + ref.read(todosProvider.notifier).add( + Todo( + title: title, + description: description, + ), + ); + } else { + ref.read(todosProvider.notifier).update( + todo!.copyWith( + title: title, + description: description, + ), + ); + } + + Navigator.of(context).pop(); + } +} diff --git a/lib/features/todos/todos_notifier.dart b/lib/features/todos/todos_notifier.dart new file mode 100644 index 0000000..9d7f520 --- /dev/null +++ b/lib/features/todos/todos_notifier.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:services/services.dart'; + +final todosProvider = + NotifierProvider>(TodosNotifier.new); + +class TodosNotifier extends Notifier> { + @override + List build() { + return service.todos; + } + + TodosService get service => ref.read(todosServiceProvider); + List get serviceTodos => List.from(service.todos); + + void add(Todo todo) { + service.add(todo); + state = serviceTodos; + } + + void update(Todo todo) { + service.update(todo); + state = serviceTodos; + } + + void remove(Todo todo) { + service.remove(todo); + state = serviceTodos; + } +} diff --git a/lib/features/todos/todos_page.dart b/lib/features/todos/todos_page.dart new file mode 100644 index 0000000..792d898 --- /dev/null +++ b/lib/features/todos/todos_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_starter/features/todos/todo_form_page.dart'; +import 'package:flutter_starter/features/todos/todos_notifier.dart'; +import 'package:flutter_starter/l10n/app_localizations.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; + +class TodosPage extends HookConsumerWidget { + const TodosPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final todos = ref.watch(todosProvider); + + return Scaffold( + appBar: AppBar(title: Text(Loc.of(context).todos)), + body: todos.isEmpty + ? const Center(child: Text('No Todos')) + : ListView.builder( + itemCount: todos.length, + itemBuilder: (context, index) { + final todo = todos[index]; + return ListTile( + leading: Checkbox( + visualDensity: VisualDensity.compact, + value: todo.completed, + onChanged: (value) => ref + .read(todosProvider.notifier) + .update(todo.copyWith(completed: value)), + ), + title: Text(todo.title), + subtitle: + todo.description != null ? Text(todo.description!) : null, + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + onPressed: () => _showTodoForm(context, todo), + icon: const Icon(Icons.edit), + ), + IconButton( + onPressed: () => + ref.read(todosProvider.notifier).remove(todo), + icon: const Icon(Icons.delete), + ), + ]), + ); + }, + ), + floatingActionButton: FloatingActionButton( + heroTag: 'todos', + onPressed: () => _showTodoForm(context, null), + tooltip: Loc.of(context).increment, + child: const Icon(Icons.add), + ), + ); + } + + void _showTodoForm(BuildContext context, Todo? todo) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TodoFormPage(todo: todo), + fullscreenDialog: true, + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b75047b..595d9e5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,15 @@ { "appName": "Flutter Starter App", + "title": "Title", + "description": "Description", + "counter": "Counter", + "increment": "Increment", + "save": "Save", + + "todos": "Todos", + "createTodo": "Create Todo", + "updateTodo": "Update Todo", + "titleRequired": "Title is required", + "youHavePushedTheButton": "You have pushed the button this many times:" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 52e4320..ec1d46f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -97,6 +97,60 @@ abstract class Loc { /// **'Flutter Starter App'** String get appName; + /// No description provided for @title. + /// + /// In en, this message translates to: + /// **'Title'** + String get title; + + /// No description provided for @description. + /// + /// In en, this message translates to: + /// **'Description'** + String get description; + + /// No description provided for @counter. + /// + /// In en, this message translates to: + /// **'Counter'** + String get counter; + + /// No description provided for @increment. + /// + /// In en, this message translates to: + /// **'Increment'** + String get increment; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @todos. + /// + /// In en, this message translates to: + /// **'Todos'** + String get todos; + + /// No description provided for @createTodo. + /// + /// In en, this message translates to: + /// **'Create Todo'** + String get createTodo; + + /// No description provided for @updateTodo. + /// + /// In en, this message translates to: + /// **'Update Todo'** + String get updateTodo; + + /// No description provided for @titleRequired. + /// + /// In en, this message translates to: + /// **'Title is required'** + String get titleRequired; + /// No description provided for @youHavePushedTheButton. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e5e66ac..f88ec4e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -7,6 +7,33 @@ class LocEn extends Loc { @override String get appName => 'Flutter Starter App'; + @override + String get title => 'Title'; + + @override + String get description => 'Description'; + + @override + String get counter => 'Counter'; + + @override + String get increment => 'Increment'; + + @override + String get save => 'Save'; + + @override + String get todos => 'Todos'; + + @override + String get createTodo => 'Create Todo'; + + @override + String get updateTodo => 'Update Todo'; + + @override + String get titleRequired => 'Title is required'; + @override String get youHavePushedTheButton => 'You have pushed the button this many times:'; } diff --git a/packages/models/.gitignore b/packages/models/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/models/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/models/CHANGELOG.md b/packages/models/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/models/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/models/README.md b/packages/models/README.md new file mode 100644 index 0000000..8c89313 --- /dev/null +++ b/packages/models/README.md @@ -0,0 +1,3 @@ +## Models + +Models shared between services and the application diff --git a/packages/models/analysis_options.yaml b/packages/models/analysis_options.yaml new file mode 100644 index 0000000..0230536 --- /dev/null +++ b/packages/models/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:lints/recommended.yaml + +linter: + rules: diff --git a/packages/models/lib/models.dart b/packages/models/lib/models.dart new file mode 100644 index 0000000..524de8d --- /dev/null +++ b/packages/models/lib/models.dart @@ -0,0 +1,3 @@ +library models; + +export 'src/models/todo.dart'; diff --git a/packages/models/lib/src/models/todo.dart b/packages/models/lib/src/models/todo.dart new file mode 100644 index 0000000..b2a87a0 --- /dev/null +++ b/packages/models/lib/src/models/todo.dart @@ -0,0 +1,53 @@ +import 'package:uuid/uuid.dart'; + +class Todo { + final String id; + final String title; + final String? description; + final bool completed; + + Todo({ + required this.title, + required this.description, + this.completed = false, + }) : id = Uuid().v4(); + + Todo._internal({ + required this.id, + required this.title, + required this.description, + required this.completed, + }); + + Todo copyWith({ + String? title, + String? description, + bool? completed, + }) { + return Todo._internal( + id: id, + title: title ?? this.title, + description: description ?? this.description, + completed: completed ?? this.completed, + ); + } + + @override + String toString() { + return 'Todo{id: $id, title: $title, description: $description, completed: $completed}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Todo && + runtimeType == other.runtimeType && + id == other.id && + title == other.title && + description == other.description && + completed == other.completed; + + @override + int get hashCode => + id.hashCode ^ title.hashCode ^ description.hashCode ^ completed.hashCode; +} diff --git a/packages/models/pubspec.yaml b/packages/models/pubspec.yaml new file mode 100644 index 0000000..7d97378 --- /dev/null +++ b/packages/models/pubspec.yaml @@ -0,0 +1,14 @@ +name: models +description: A starting point for Dart libraries or applications. +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.2.3 + +dependencies: + uuid: ^4.2.2 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/packages/models/test/models/todo_test.dart b/packages/models/test/models/todo_test.dart new file mode 100644 index 0000000..cbacb17 --- /dev/null +++ b/packages/models/test/models/todo_test.dart @@ -0,0 +1,35 @@ +import 'package:models/src/models/todo.dart'; +import 'package:test/test.dart'; + +void main() { + group('Todo', () { + test('should create a Todo instance', () { + final todo = Todo( + title: 'Buy groceries', + description: 'Buy groceries for dinner', + completed: false, + ); + + expect(todo.id.isNotEmpty, true); + expect(todo.title, 'Buy groceries'); + expect(todo.completed, false); + }); + + test('copyWith should return a new Todo instance', () { + final todo = Todo( + title: 'Buy groceries', + description: 'Buy groceries for dinner', + completed: false, + ); + + final newTodo = todo.copyWith( + completed: true, + ); + + expect(newTodo.id, todo.id); + expect(newTodo.title, todo.title); + expect(newTodo.description, todo.description); + expect(newTodo.completed, true); + }); + }); +} diff --git a/packages/services/.gitignore b/packages/services/.gitignore new file mode 100644 index 0000000..3cceda5 --- /dev/null +++ b/packages/services/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/services/CHANGELOG.md b/packages/services/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/services/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/services/README.md b/packages/services/README.md new file mode 100644 index 0000000..7522c0c --- /dev/null +++ b/packages/services/README.md @@ -0,0 +1,3 @@ +## Services page + +Services used by the application. diff --git a/packages/services/analysis_options.yaml b/packages/services/analysis_options.yaml new file mode 100644 index 0000000..0230536 --- /dev/null +++ b/packages/services/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:lints/recommended.yaml + +linter: + rules: diff --git a/packages/services/lib/services.dart b/packages/services/lib/services.dart new file mode 100644 index 0000000..5bec615 --- /dev/null +++ b/packages/services/lib/services.dart @@ -0,0 +1,4 @@ +library services; + +export 'src/providers.dart'; +export 'src/services/todos_service.dart'; diff --git a/packages/services/lib/src/providers.dart b/packages/services/lib/src/providers.dart new file mode 100644 index 0000000..31b34b1 --- /dev/null +++ b/packages/services/lib/src/providers.dart @@ -0,0 +1,6 @@ +import 'package:riverpod/riverpod.dart'; +import 'package:services/services.dart'; + +final todosServiceProvider = Provider((ref) => TodosService()); + +List serviceOverrides() => []; diff --git a/packages/services/lib/src/services/todos_service.dart b/packages/services/lib/src/services/todos_service.dart new file mode 100644 index 0000000..3966aaa --- /dev/null +++ b/packages/services/lib/src/services/todos_service.dart @@ -0,0 +1,18 @@ +import 'package:models/models.dart'; + +class TodosService { + List todos = []; + + void add(Todo todo) { + todos.add(todo); + } + + void remove(Todo todo) { + todos.remove(todo); + } + + void update(Todo todo) { + final index = todos.indexWhere((element) => element.id == todo.id); + todos[index] = todo; + } +} diff --git a/packages/services/pubspec.yaml b/packages/services/pubspec.yaml new file mode 100644 index 0000000..a876acf --- /dev/null +++ b/packages/services/pubspec.yaml @@ -0,0 +1,17 @@ +name: services +description: Application services +version: 1.0.0 +publish_to: none + +environment: + sdk: ^3.2.3 + +dependencies: + models: + path: ../models + + riverpod: ^2.4.9 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/packages/services/test/services/todos_service_test.dart b/packages/services/test/services/todos_service_test.dart new file mode 100644 index 0000000..861255e --- /dev/null +++ b/packages/services/test/services/todos_service_test.dart @@ -0,0 +1,59 @@ +import 'package:models/models.dart'; +import 'package:services/services.dart'; +import 'package:test/test.dart'; + +void main() { + late TodosService todosService; + + setUp(() { + todosService = TodosService(); + }); + + group('TodosService', () { + test('should add a todo', () { + final todo = Todo( + title: 'Buy groceries', + description: 'Buy groceries for dinner', + completed: false, + ); + + todosService.add(todo); + + expect(todosService.todos.length, 1); + expect(todosService.todos.first, todo); + }); + + test('should update a todo', () { + final todo = Todo( + title: 'Buy groceries', + description: 'Buy groceries for dinner', + completed: false, + ); + + todosService.add(todo); + + final newTodo = todo.copyWith( + completed: true, + ); + + todosService.update(newTodo); + + expect(todosService.todos.length, 1); + expect(todosService.todos.first, newTodo); + }); + + test('should remove a todo', () { + final todo = Todo( + title: 'Buy groceries', + description: 'Buy groceries for dinner', + completed: false, + ); + + todosService.add(todo); + + todosService.remove(todo); + + expect(todosService.todos.length, 0); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index af9a7eb..51d2a18 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" fake_async: dependency: transitive description: @@ -136,6 +144,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + models: + dependency: "direct main" + description: + path: "packages/models" + relative: true + source: path + version: "1.0.0" path: dependency: transitive description: @@ -152,6 +167,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.9" + services: + dependency: "direct main" + description: + path: "packages/services" + relative: true + source: path + version: "1.0.0" sky_engine: dependency: transitive description: flutter @@ -165,6 +187,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -213,6 +243,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: "22c94e5ad1e75f9934b766b53c742572ee2677c56bc871d850a57dad0f82127f" + url: "https://pub.dev" + source: hosted + version: "4.2.2" vector_math: dependency: transitive description: @@ -230,5 +276,5 @@ packages: source: hosted version: "0.3.0" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" + dart: ">=3.2.3 <4.0.0" flutter: ">=3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 6fe14e6..c184b74 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,11 @@ dependencies: flutter_localizations: sdk: flutter + models: + path: ./packages/models + services: + path: ./packages/services + flutter_hooks: ^0.20.3 hooks_riverpod: ^2.4.9 intl: ^0.18.1 diff --git a/test/helpers/widget_helpers.dart b/test/helpers/widget_helpers.dart new file mode 100644 index 0000000..07e254a --- /dev/null +++ b/test/helpers/widget_helpers.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_starter/l10n/app_localizations.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class WidgetHelpers { + static Widget testableWidget({ + required Widget child, + List overrides = const [], + }) { + return ProviderScope( + overrides: overrides, + child: MaterialApp( + localizationsDelegates: const [ + Loc.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [ + Locale('en', ''), + ], + home: Scaffold(body: child), + ), + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index c141ed7..2d55371 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,22 +1,17 @@ -import 'package:flutter/material.dart'; import 'package:flutter_starter/features/app/app.dart'; +import 'package:flutter_starter/features/counter/counter_page.dart'; +import 'package:flutter_starter/features/todos/todos_page.dart'; import 'package:flutter_test/flutter_test.dart'; -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const App()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); +import 'helpers/widget_helpers.dart'; - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); +void main() { + testWidgets('App starts on CounterPage', (WidgetTester tester) async { + await tester.pumpWidget( + WidgetHelpers.testableWidget(child: const App()), + ); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.byType(CounterPage), findsOneWidget); + expect(find.byType(TodosPage), findsNothing); }); }