diff --git a/lib/todo.dart b/lib/features/todo/todo.dart similarity index 56% rename from lib/todo.dart rename to lib/features/todo/todo.dart index 4e7a908..c84b210 100644 --- a/lib/todo.dart +++ b/lib/features/todo/todo.dart @@ -1,18 +1,30 @@ +import 'package:todo/shared/database/database.dart'; + class Todo { + final String id; final String title; final bool completed; Todo({ + required this.id, required this.title, this.completed = false, }); + TodoEntity toDatabase() { + return TodoEntity( + id: id, + title: title, + completed: completed, + ); + } + Todo copyWith({ String? title, - String? description, bool? completed, }) { return Todo( + id: id, title: title ?? this.title, completed: completed ?? this.completed, ); diff --git a/lib/features/todo/todo_local_data_source.dart b/lib/features/todo/todo_local_data_source.dart new file mode 100644 index 0000000..4b3192d --- /dev/null +++ b/lib/features/todo/todo_local_data_source.dart @@ -0,0 +1,32 @@ +import 'package:todo/shared/database/database.dart'; + +class TodoLocalDataSource { + TodoLocalDataSource({required AppDatabase database}) : _database = database; + final AppDatabase _database; + + Stream> listenAll() { + final query = _database.select(_database.todoEntities); + + return query.watch(); + } + + Future add(String text) async { + final insert = TodoEntitiesCompanion.insert( + title: text, + ); + await _database.into(_database.todoEntities).insert(insert); + } + + Future remove(TodoEntity todo) async { + final query = _database.delete(_database.todoEntities) + ..where((tbl) => tbl.id.equals(todo.id)); + await query.go(); + } + + Future update(TodoEntity todo) async { + final query = _database.update(_database.todoEntities) + ..where((tbl) => tbl.id.equals(todo.id)); + + await query.write(todo); + } +} diff --git a/lib/features/todo/todo_page.dart b/lib/features/todo/todo_page.dart index 178348d..5c67f76 100644 --- a/lib/features/todo/todo_page.dart +++ b/lib/features/todo/todo_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:todo/features/todo/todo_page_view_model.dart'; +import 'package:todo/features/todo/todo_repository.dart'; import 'package:todo/main.dart'; +import 'package:todo/shared/date_service.dart'; +import 'package:todo/shared/locator.dart'; import 'package:todo/shared/ui_utilities/value_listenable_builder_x.dart'; -import 'package:todo/todo.dart'; class TodoPage extends StatefulWidget { const TodoPage({super.key}); @@ -13,8 +15,8 @@ class TodoPage extends StatefulWidget { class _TodoPageState extends State { late final homePageViewModel = TodoPageViewModel( - dateService: dateService, - todoRepository: todoRepository, + dateService: locator(), + todoRepository: locator(), ); final TextEditingController _todoController = TextEditingController(); @@ -53,9 +55,7 @@ class _TodoPageState extends State { TextButton( onPressed: () { homePageViewModel.add( - Todo( - title: _todoController.text, - ), + title: _todoController.text, ); _todoController.clear(); @@ -81,7 +81,7 @@ class _TodoPageState extends State { ), actions: [ ValueListenableBuilder2( - first: homePageViewModel.todos, + first: homePageViewModel.todosNotifier, second: homePageViewModel.showCompletedTodos, builder: (context, todos, showCompletedTodos, child) { if (homePageViewModel.hasNonCompletedTodos) { @@ -102,7 +102,7 @@ class _TodoPageState extends State { body: TodoList( toggleDone: homePageViewModel.toggleDone, removeTodo: homePageViewModel.remove, - todosNotifier: homePageViewModel.todos, + todosNotifier: homePageViewModel.todosNotifier, showCompletedTodos: homePageViewModel.showCompletedTodos, ), floatingActionButton: Row( diff --git a/lib/features/todo/todo_page_view_model.dart b/lib/features/todo/todo_page_view_model.dart index d9a7522..25e60cf 100644 --- a/lib/features/todo/todo_page_view_model.dart +++ b/lib/features/todo/todo_page_view_model.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:todo/features/todo/todo.dart'; import 'package:todo/features/todo/todo_repository.dart'; import 'package:todo/shared/date_service.dart'; -import 'package:todo/todo.dart'; /// the viewmodel which is responsible for business logic of the page /// this should be fully unit testable and dependencies should be constructor injected @@ -19,22 +19,33 @@ class TodoPageViewModel { ValueNotifier get serviceDate => _dateService.dateNotifier; - final ValueNotifier> todos = ValueNotifier([]); + final ValueNotifier> todosNotifier = ValueNotifier([]); final ValueNotifier showCompletedTodos = ValueNotifier(false); + StreamSubscription>? _subscription; + bool get hasNonCompletedTodos => - todos.value.where((element) => element.completed).isNotEmpty; + todosNotifier.value.where((element) => element.completed).isNotEmpty; - Future init() async { - _todoRepository.todos.addListener(onTodosUpdate); + void init() { + _listenToTodos(); } - void onTodosUpdate() { - todos.value = _todoRepository.todos.value; + void _listenToTodos() { + _subscription?.cancel(); + _subscription = null; + + final stream = _todoRepository.listenAll(); + + _subscription = stream.listen( + (todos) { + todosNotifier.value = todos; + }, + ); } - Future add(Todo todo) async { - _todoRepository.addTodo(todo); + Future add({required String title}) async { + _todoRepository.addTodo(title: title); } Future remove(Todo todo) async { @@ -54,6 +65,7 @@ class TodoPageViewModel { } void dispose() { - _todoRepository.todos.removeListener(onTodosUpdate); + _subscription?.cancel(); + _subscription = null; } } diff --git a/lib/features/todo/todo_repository.dart b/lib/features/todo/todo_repository.dart index 4195f0c..7fd5385 100644 --- a/lib/features/todo/todo_repository.dart +++ b/lib/features/todo/todo_repository.dart @@ -1,27 +1,31 @@ -import 'package:flutter/foundation.dart'; -import 'package:todo/todo.dart'; +import 'package:todo/features/todo/todo.dart'; +import 'package:todo/features/todo/todo_local_data_source.dart'; +import 'package:todo/shared/database/database.dart'; class TodoRepository { - TodoRepository(); + TodoRepository({required TodoLocalDataSource todoLocalDataSource}) + : _todoLocalDataSource = todoLocalDataSource; - // this should live in a server or local storage and we should provide a listenable approach to this item - // ValueNotifier here is just an example but most storage solutions and packages provide a reactive way to get data. - final ValueNotifier> todos = ValueNotifier([]); + final TodoLocalDataSource _todoLocalDataSource; - void addTodo(Todo todo) { - todos.value = [...todos.value, todo]; + Stream> listenAll() { + return _todoLocalDataSource.listenAll().map((todos) { + return todos.map((todo) { + return todo.toTodo(); + }).toList(); + }); } - void removeTodo(Todo todo) { - todos.value = todos.value.where((element) => element != todo).toList(); + Future addTodo({required String title}) async { + await _todoLocalDataSource.add(title); } - void toggleDone(Todo todo) { - todos.value = todos.value.map((oldTodo) { - if (oldTodo == todo) { - return oldTodo.copyWith(completed: !oldTodo.completed); - } - return oldTodo; - }).toList(); + Future removeTodo(Todo todo) async { + await _todoLocalDataSource.remove(todo.toDatabase()); + } + + Future toggleDone(Todo todo) async { + final toggledTodo = todo.copyWith(completed: !todo.completed); + await _todoLocalDataSource.update(toggledTodo.toDatabase()); } } diff --git a/lib/main.dart b/lib/main.dart index b204e76..5db30e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:todo/features/todo/todo.dart'; import 'package:todo/features/todo/todo_page.dart'; -import 'package:todo/features/todo/todo_repository.dart'; -import 'package:todo/shared/date_service.dart'; -import 'package:todo/todo.dart'; - -// app wide dependencies, consider using GetIt to override -// dependencies in tests. -late final DateService dateService; -late final TodoRepository todoRepository; +import 'package:todo/shared/locator.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - dateService = DateService(); - todoRepository = TodoRepository(); + // initialize our service, repositories and other app wide classes + setupLocators(); runApp( MaterialApp( diff --git a/lib/shared/database/database.dart b/lib/shared/database/database.dart new file mode 100644 index 0000000..ca3c00c --- /dev/null +++ b/lib/shared/database/database.dart @@ -0,0 +1,38 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:todo/features/todo/todo.dart'; +import 'package:uuid/uuid.dart'; + +part 'database.g.dart'; + +const _uuid = Uuid(); + +extension TodoItemX on TodoEntity { + Todo toTodo() { + return Todo( + id: id, + title: title, + completed: completed, + ); + } +} + +class TodoEntities extends Table { + TextColumn get id => text().clientDefault(() => _uuid.v4())(); + TextColumn get title => text()(); + BoolColumn get completed => boolean().withDefault(const Constant(false))(); +} + +@DriftDatabase(tables: [ + TodoEntities, +]) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + @override + int get schemaVersion => 1; + + static QueryExecutor _openConnection() { + return driftDatabase(name: 'database'); + } +} diff --git a/lib/shared/database/database.g.dart b/lib/shared/database/database.g.dart new file mode 100644 index 0000000..e6348f3 --- /dev/null +++ b/lib/shared/database/database.g.dart @@ -0,0 +1,365 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $TodoEntitiesTable extends TodoEntities + with TableInfo<$TodoEntitiesTable, TodoEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TodoEntitiesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + clientDefault: () => _uuid.v4()); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _completedMeta = + const VerificationMeta('completed'); + @override + late final GeneratedColumn completed = GeneratedColumn( + 'completed', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("completed" IN (0, 1))'), + defaultValue: const Constant(false)); + @override + List get $columns => [id, title, completed]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'todo_entities'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, title.isAcceptableOrUnknown(data['title']!, _titleMeta)); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('completed')) { + context.handle(_completedMeta, + completed.isAcceptableOrUnknown(data['completed']!, _completedMeta)); + } + return context; + } + + @override + Set get $primaryKey => const {}; + @override + TodoEntity map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TodoEntity( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + completed: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}completed'])!, + ); + } + + @override + $TodoEntitiesTable createAlias(String alias) { + return $TodoEntitiesTable(attachedDatabase, alias); + } +} + +class TodoEntity extends DataClass implements Insertable { + final String id; + final String title; + final bool completed; + const TodoEntity( + {required this.id, required this.title, required this.completed}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['completed'] = Variable(completed); + return map; + } + + TodoEntitiesCompanion toCompanion(bool nullToAbsent) { + return TodoEntitiesCompanion( + id: Value(id), + title: Value(title), + completed: Value(completed), + ); + } + + factory TodoEntity.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TodoEntity( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + completed: serializer.fromJson(json['completed']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'completed': serializer.toJson(completed), + }; + } + + TodoEntity copyWith({String? id, String? title, bool? completed}) => + TodoEntity( + id: id ?? this.id, + title: title ?? this.title, + completed: completed ?? this.completed, + ); + TodoEntity copyWithCompanion(TodoEntitiesCompanion data) { + return TodoEntity( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + completed: data.completed.present ? data.completed.value : this.completed, + ); + } + + @override + String toString() { + return (StringBuffer('TodoEntity(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('completed: $completed') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, title, completed); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TodoEntity && + other.id == this.id && + other.title == this.title && + other.completed == this.completed); +} + +class TodoEntitiesCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value completed; + final Value rowid; + const TodoEntitiesCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.completed = const Value.absent(), + this.rowid = const Value.absent(), + }); + TodoEntitiesCompanion.insert({ + this.id = const Value.absent(), + required String title, + this.completed = const Value.absent(), + this.rowid = const Value.absent(), + }) : title = Value(title); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? completed, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (completed != null) 'completed': completed, + if (rowid != null) 'rowid': rowid, + }); + } + + TodoEntitiesCompanion copyWith( + {Value? id, + Value? title, + Value? completed, + Value? rowid}) { + return TodoEntitiesCompanion( + id: id ?? this.id, + title: title ?? this.title, + completed: completed ?? this.completed, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (completed.present) { + map['completed'] = Variable(completed.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TodoEntitiesCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('completed: $completed, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $TodoEntitiesTable todoEntities = $TodoEntitiesTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [todoEntities]; +} + +typedef $$TodoEntitiesTableCreateCompanionBuilder = TodoEntitiesCompanion + Function({ + Value id, + required String title, + Value completed, + Value rowid, +}); +typedef $$TodoEntitiesTableUpdateCompanionBuilder = TodoEntitiesCompanion + Function({ + Value id, + Value title, + Value completed, + Value rowid, +}); + +class $$TodoEntitiesTableFilterComposer + extends FilterComposer<_$AppDatabase, $TodoEntitiesTable> { + $$TodoEntitiesTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get completed => $state.composableBuilder( + column: $state.table.completed, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$TodoEntitiesTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $TodoEntitiesTable> { + $$TodoEntitiesTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get completed => $state.composableBuilder( + column: $state.table.completed, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +class $$TodoEntitiesTableTableManager extends RootTableManager< + _$AppDatabase, + $TodoEntitiesTable, + TodoEntity, + $$TodoEntitiesTableFilterComposer, + $$TodoEntitiesTableOrderingComposer, + $$TodoEntitiesTableCreateCompanionBuilder, + $$TodoEntitiesTableUpdateCompanionBuilder, + (TodoEntity, BaseReferences<_$AppDatabase, $TodoEntitiesTable, TodoEntity>), + TodoEntity, + PrefetchHooks Function()> { + $$TodoEntitiesTableTableManager(_$AppDatabase db, $TodoEntitiesTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$TodoEntitiesTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$TodoEntitiesTableOrderingComposer(ComposerState(db, table)), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value completed = const Value.absent(), + Value rowid = const Value.absent(), + }) => + TodoEntitiesCompanion( + id: id, + title: title, + completed: completed, + rowid: rowid, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String title, + Value completed = const Value.absent(), + Value rowid = const Value.absent(), + }) => + TodoEntitiesCompanion.insert( + id: id, + title: title, + completed: completed, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$TodoEntitiesTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $TodoEntitiesTable, + TodoEntity, + $$TodoEntitiesTableFilterComposer, + $$TodoEntitiesTableOrderingComposer, + $$TodoEntitiesTableCreateCompanionBuilder, + $$TodoEntitiesTableUpdateCompanionBuilder, + (TodoEntity, BaseReferences<_$AppDatabase, $TodoEntitiesTable, TodoEntity>), + TodoEntity, + PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$TodoEntitiesTableTableManager get todoEntities => + $$TodoEntitiesTableTableManager(_db, _db.todoEntities); +} diff --git a/lib/shared/locator.dart b/lib/shared/locator.dart new file mode 100644 index 0000000..4f8f005 --- /dev/null +++ b/lib/shared/locator.dart @@ -0,0 +1,24 @@ +import 'package:get_it/get_it.dart'; +import 'package:todo/features/todo/todo_local_data_source.dart'; +import 'package:todo/features/todo/todo_repository.dart'; +import 'package:todo/shared/database/database.dart'; +import 'package:todo/shared/date_service.dart'; + +final locator = GetIt.instance; + +void setupLocators() { + locator.registerLazySingleton(() => DateService()); + locator.registerLazySingleton(() => AppDatabase()); + + locator.registerLazySingleton( + () => TodoLocalDataSource( + database: locator(), + ), + ); + + locator.registerLazySingleton( + () => TodoRepository( + todoLocalDataSource: locator(), + ), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 035b1f7..fba39ac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,10 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.2 + get_it: ^8.0.0 + drift: ^2.20.2 + drift_flutter: ^0.1.0 + uuid: ^4.5.1 dev_dependencies: flutter_test: @@ -41,7 +45,9 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^4.0.0 + flutter_lints: ^5.0.0 + drift_dev: ^2.20.3 + build_runner: ^2.4.12 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec