From d284aca4f69ed7ad81014205ecce7349ac53542c Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 1 Apr 2019 12:27:13 +0200 Subject: [PATCH] Improve examples app, fix many issues with joins --- .../lib/src => moor/lib}/sqlite_keywords.dart | 7 +- moor/lib/src/runtime/statements/insert.dart | 10 +- moor/lib/src/runtime/statements/select.dart | 39 +++++ moor/lib/src/runtime/structure/columns.dart | 9 +- moor/test/data/tables/todos.g.dart | 8 +- .../expressions/datetime_expression_test.dart | 14 +- moor/test/insert_test.dart | 7 + moor/test/join_test.dart | 22 +-- moor_flutter/example/lib/bloc.dart | 84 +++++++++- .../example/lib/database/database.dart | 102 +++++++----- .../example/lib/database/database.g.dart | 8 +- .../example/lib/database/todos_dao.dart | 20 --- .../example/lib/database/todos_dao.g.dart | 11 -- moor_flutter/example/lib/main.dart | 8 +- .../lib/widgets/add_category_dialog.dart | 56 +++++++ .../lib/widgets/categories_drawer.dart | 153 ++++++++++++++++++ .../example/lib/widgets/homescreen.dart | 20 +-- .../example/lib/widgets/todo_card.dart | 25 +-- .../example/lib/widgets/todo_edit_dialog.dart | 50 +++++- moor_flutter/example/pubspec.yaml | 2 +- .../lib/src/model/specified_column.dart | 9 -- .../lib/src/parser/column_parser.dart | 2 +- .../lib/src/parser/table_parser.dart | 2 +- 23 files changed, 499 insertions(+), 169 deletions(-) rename {moor_generator/lib/src => moor/lib}/sqlite_keywords.dart (94%) delete mode 100644 moor_flutter/example/lib/database/todos_dao.dart delete mode 100644 moor_flutter/example/lib/database/todos_dao.g.dart create mode 100644 moor_flutter/example/lib/widgets/add_category_dialog.dart create mode 100644 moor_flutter/example/lib/widgets/categories_drawer.dart diff --git a/moor_generator/lib/src/sqlite_keywords.dart b/moor/lib/sqlite_keywords.dart similarity index 94% rename from moor_generator/lib/src/sqlite_keywords.dart rename to moor/lib/sqlite_keywords.dart index 7c0a501b..210d3fd1 100644 --- a/moor_generator/lib/src/sqlite_keywords.dart +++ b/moor/lib/sqlite_keywords.dart @@ -1,5 +1,8 @@ +/// Provides utilities around sql keywords, like optional escaping etc. +library moor.sqlite_keywords; + // https://www.sqlite.org/lang_keywords.html -const sqliteKeywords = [ +const sqliteKeywords = { 'ABORT', 'ACTION', 'ADD', @@ -136,7 +139,7 @@ const sqliteKeywords = [ 'WINDOW', 'WITH', 'WITHOUT' -]; +}; bool isSqliteKeyword(String s) => sqliteKeywords.contains(s.toUpperCase()); diff --git a/moor/lib/src/runtime/statements/insert.dart b/moor/lib/src/runtime/statements/insert.dart index 3c7e3290..4aefb4f2 100644 --- a/moor/lib/src/runtime/statements/insert.dart +++ b/moor/lib/src/runtime/statements/insert.dart @@ -22,7 +22,10 @@ class InsertStatement { /// thrown. An insert will also fail if another row with the same primary key /// or unique constraints already exists. If you want to override data in that /// case, use [insertOrReplace] instead. - Future insert(DataClass entity) async { + /// + /// If the table contains an auto-increment column, the generated value will + /// be returned. + Future insert(DataClass entity) async { if (!table.validateIntegrity(entity, true)) { throw InvalidDataException( 'Invalid data: $entity cannot be written into ${table.$tableName}'); @@ -54,9 +57,10 @@ class InsertStatement { ctx.buffer.write(')'); - await database.executor.doWhenOpened((e) async { - await database.executor.runInsert(ctx.sql, ctx.boundVariables); + return await database.executor.doWhenOpened((e) async { + final id = await database.executor.runInsert(ctx.sql, ctx.boundVariables); database.markTablesUpdated({table}); + return id; }); } diff --git a/moor/lib/src/runtime/statements/select.dart b/moor/lib/src/runtime/statements/select.dart index a27e8f01..25a4f8d3 100644 --- a/moor/lib/src/runtime/statements/select.dart +++ b/moor/lib/src/runtime/statements/select.dart @@ -4,8 +4,10 @@ import 'package:meta/meta.dart'; import 'package:moor/moor.dart'; import 'package:moor/src/runtime/components/component.dart'; import 'package:moor/src/runtime/components/join.dart'; +import 'package:moor/src/runtime/components/where.dart'; import 'package:moor/src/runtime/database.dart'; import 'package:moor/src/runtime/executor/stream_queries.dart'; +import 'package:moor/src/runtime/expressions/expression.dart'; import 'package:moor/src/runtime/statements/query.dart'; import 'package:moor/src/runtime/structure/table_info.dart'; @@ -38,7 +40,17 @@ class JoinedSelectStatement extends Query ctx.buffer.write(', '); } + // We run into problems when two tables have a column with the same name + // as we then wouldn't know which column is which. So, we create a + // column alias that matches what is expected by the mapping function + // in _getWithQuery by prefixing the table name. + // We might switch to parsing via the index of the column in a row in + // the future, but that's the solution for now. + column.writeInto(ctx); + ctx.buffer.write(' AS "'); + column.writeInto(ctx, ignoreEscape: true); + ctx.buffer.write('"'); isFirst = false; } @@ -57,9 +69,36 @@ class JoinedSelectStatement extends Query } } + void where(Expression predicate) { + if (whereExpr == null) { + whereExpr = Where(predicate); + } else { + whereExpr = Where(and(whereExpr.predicate, predicate)); + } + } + + void orderBy(List terms) { + orderByExpr = OrderBy(terms); + } + + Stream> watch() { + final ctx = constructQuery(); + final fetcher = QueryStreamFetcher>( + readsFrom: watchedTables, + fetchData: () => _getWithQuery(ctx), + key: StreamKey(ctx.sql, ctx.boundVariables, TypedResult), + ); + + return database.createStream(fetcher); + } + /// Executes this statement and returns the result. Future> get() async { final ctx = constructQuery(); + return _getWithQuery(ctx); + } + + Future> _getWithQuery(GenerationContext ctx) async { final results = await ctx.database.executor.doWhenOpened((e) async { return await e.runSelect(ctx.sql, ctx.boundVariables); }); diff --git a/moor/lib/src/runtime/structure/columns.dart b/moor/lib/src/runtime/structure/columns.dart index 0fe2b8e0..dacd6672 100644 --- a/moor/lib/src/runtime/structure/columns.dart +++ b/moor/lib/src/runtime/structure/columns.dart @@ -7,12 +7,15 @@ import 'package:moor/src/runtime/expressions/expression.dart'; import 'package:moor/src/runtime/expressions/text.dart'; import 'package:moor/src/runtime/expressions/variables.dart'; import 'package:moor/src/types/sql_types.dart'; +import 'package:moor/sqlite_keywords.dart'; /// Base class for the implementation of [Column]. abstract class GeneratedColumn> extends Column { /// The sql name of this column. final String $name; + String get escapedName => escapeIfNeeded($name); + /// The name of the table that contains this column final String tableName; @@ -31,7 +34,7 @@ abstract class GeneratedColumn> extends Column { /// [here](https://www.sqlite.org/syntax/column-def.html), into the given /// buffer. void writeColumnDefinition(StringBuffer into) { - into.write('${$name} $typeName '); + into.write('$escapedName $typeName '); if ($customConstraints == null) { into.write($nullable ? 'NULL' : 'NOT NULL'); @@ -50,11 +53,11 @@ abstract class GeneratedColumn> extends Column { String get typeName; @override - void writeInto(GenerationContext context) { + void writeInto(GenerationContext context, {bool ignoreEscape = false}) { if (context.hasMultipleTables) { context.buffer..write(tableName)..write('.'); } - context.buffer.write($name); + context.buffer.write(ignoreEscape ? $name : escapedName); } /// Checks whether the given value fits into this column. The default diff --git a/moor/test/data/tables/todos.g.dart b/moor/test/data/tables/todos.g.dart index 8f792825..df233969 100644 --- a/moor/test/data/tables/todos.g.dart +++ b/moor/test/data/tables/todos.g.dart @@ -221,7 +221,7 @@ class Category { return Category( id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']), description: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}`desc`']), + stringType.mapFromDatabaseResponse(data['${effectivePrefix}desc']), ); } factory Category.fromJson(Map json) { @@ -277,9 +277,9 @@ class $CategoriesTable extends Categories GeneratedTextColumn get description => _description ??= _constructDescription(); GeneratedTextColumn _constructDescription() { - var cName = '`desc`'; + var cName = 'desc'; if (_alias != null) cName = '$_alias.$cName'; - return GeneratedTextColumn('`desc`', $tableName, false, + return GeneratedTextColumn('desc', $tableName, false, $customConstraints: 'NOT NULL UNIQUE'); } @@ -310,7 +310,7 @@ class $CategoriesTable extends Categories map['id'] = Variable(d.id); } if (d.description != null || includeNulls) { - map['`desc`'] = Variable(d.description); + map['desc'] = Variable(d.description); } return map; } diff --git a/moor/test/expressions/datetime_expression_test.dart b/moor/test/expressions/datetime_expression_test.dart index 5c3e3393..347e3dfd 100644 --- a/moor/test/expressions/datetime_expression_test.dart +++ b/moor/test/expressions/datetime_expression_test.dart @@ -9,14 +9,14 @@ typedef Expression _Extractor( /// Tests the top level [year], [month], ..., [second] methods void main() { final expectedResults = <_Extractor, String>{ - year: 'CAST(strftime("%Y", column, "unixepoch") AS INTEGER)', - month: 'CAST(strftime("%m", column, "unixepoch") AS INTEGER)', - day: 'CAST(strftime("%d", column, "unixepoch") AS INTEGER)', - hour: 'CAST(strftime("%H", column, "unixepoch") AS INTEGER)', - minute: 'CAST(strftime("%M", column, "unixepoch") AS INTEGER)', - second: 'CAST(strftime("%S", column, "unixepoch") AS INTEGER)', + year: 'CAST(strftime("%Y", val, "unixepoch") AS INTEGER)', + month: 'CAST(strftime("%m", val, "unixepoch") AS INTEGER)', + day: 'CAST(strftime("%d", val, "unixepoch") AS INTEGER)', + hour: 'CAST(strftime("%H", val, "unixepoch") AS INTEGER)', + minute: 'CAST(strftime("%M", val, "unixepoch") AS INTEGER)', + second: 'CAST(strftime("%S", val, "unixepoch") AS INTEGER)', }; - final column = GeneratedDateTimeColumn('column', null, false); + final column = GeneratedDateTimeColumn('val', null, false); expectedResults.forEach((key, value) { test('should extract field', () { diff --git a/moor/test/insert_test.dart b/moor/test/insert_test.dart index 4872dda4..c4dffbf7 100644 --- a/moor/test/insert_test.dart +++ b/moor/test/insert_test.dart @@ -55,4 +55,11 @@ void main() { throwsA(const TypeMatcher()), ); }); + + test('reports auto-increment id', () async { + when(executor.runInsert(any, any)).thenAnswer((_) => Future.value(42)); + + expect(db.into(db.todosTable).insert(TodoEntry(content: 'Bottom text')), + completion(42)); + }); } diff --git a/moor/test/join_test.dart b/moor/test/join_test.dart index 3c278200..8bfa5d12 100644 --- a/moor/test/join_test.dart +++ b/moor/test/join_test.dart @@ -13,19 +13,6 @@ void main() { }); test('generates join statements', () async { - await db.select(db.todosTable).join([ - leftOuterJoin( - db.categories, db.categories.id.equalsExp(db.todosTable.category)) - ]).get(); - - verify(executor.runSelect( - 'SELECT todos.id, todos.title, todos.content, todos.target_date, ' - 'todos.category, categories.id, categories.`desc` FROM todos ' - 'LEFT OUTER JOIN categories ON categories.id = todos.category;', - argThat(isEmpty))); - }); - - test('generates join statements with table aliases', () async { final todos = db.alias(db.todosTable, 't'); final categories = db.alias(db.categories, 'c'); @@ -34,9 +21,10 @@ void main() { ]).get(); verify(executor.runSelect( - 'SELECT t.id, t.title, t.content, t.target_date, ' - 't.category, c.id, c.`desc` FROM todos t ' - 'LEFT OUTER JOIN categories c ON c.id = t.category;', + 'SELECT t.id AS "t.id", t.title AS "t.title", t.content AS "t.content", ' + 't.target_date AS "t.target_date", ' + 't.category AS "t.category", c.id AS "c.id", c.`desc` AS "c.desc" ' + 'FROM todos t LEFT OUTER JOIN categories c ON c.id = t.category;', argThat(isEmpty))); }); @@ -54,7 +42,7 @@ void main() { 't.target_date': date.millisecondsSinceEpoch ~/ 1000, 't.category': 3, 'c.id': 3, - 'c.`desc`': 'description', + 'c.desc': 'description', } ]); }); diff --git a/moor_flutter/example/lib/bloc.dart b/moor_flutter/example/lib/bloc.dart index 87127725..fd86cd0b 100644 --- a/moor_flutter/example/lib/bloc.dart +++ b/moor_flutter/example/lib/bloc.dart @@ -1,14 +1,86 @@ -import 'dart:async'; - import 'package:moor_example/database/database.dart'; +import 'package:rxdart/rxdart.dart'; + +/// Class that keeps information about a category and whether it's selected at +/// the moment. +class CategoryWithActiveInfo { + CategoryWithCount categoryWithCount; + bool isActive; + + CategoryWithActiveInfo(this.categoryWithCount, this.isActive); +} class TodoAppBloc { final Database db; - Stream> get allEntries => db.allEntries(); - TodoAppBloc() : db = Database(); + // the category that is selected at the moment. null means that we show all + // entries + final BehaviorSubject _activeCategory = + BehaviorSubject.seeded(null); - void addEntry(TodoEntry entry) { - db.addEntry(entry); + Observable> _currentEntries; + + /// A stream of entries that should be displayed on the home screen. + Observable> get homeScreenEntries => _currentEntries; + + final BehaviorSubject> _allCategories = + BehaviorSubject(); + Observable> get categories => _allCategories; + + TodoAppBloc() : db = Database() { + // listen for the category to change. Then display all entries that are in + // the current category on the home screen. + _currentEntries = _activeCategory.switchMap(db.watchEntriesInCategory); + + // also watch all categories so that they can be displayed in the navigation + // drawer. + Observable.combineLatest2, Category, + List>( + db.categoriesWithCount(), + _activeCategory, + (allCategories, selected) { + return allCategories.map((category) { + final isActive = selected?.id == category.category?.id; + + return CategoryWithActiveInfo(category, isActive); + }).toList(); + }, + ).listen(_allCategories.add); + } + + void showCategory(Category category) { + _activeCategory.add(category); + } + + void addCategory(String description) async { + final category = Category(description: description); + final id = await db.createCategory(category); + + showCategory(category.copyWith(id: id)); + } + + void createEntry(String content) { + db.createEntry(TodoEntry( + content: content, + category: _activeCategory.value?.id, + )); + } + + void updateEntry(TodoEntry entry) { + db.updateEntry(entry); + } + + void deleteEntry(TodoEntry entry) { + db.deleteEntry(entry); + } + + void deleteCategory(Category category) { + // if the category being deleted is the one selected, reset that state by + // showing the entries who aren't in any category + if (_activeCategory.value?.id == category.id) { + showCategory(null); + } + + db.deleteCategory(category); } } diff --git a/moor_flutter/example/lib/database/database.dart b/moor_flutter/example/lib/database/database.dart index 627a27e0..b7a571d2 100644 --- a/moor_flutter/example/lib/database/database.dart +++ b/moor_flutter/example/lib/database/database.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:moor_example/database/todos_dao.dart'; import 'package:moor_flutter/moor_flutter.dart'; part 'database.g.dart'; @@ -25,6 +24,7 @@ class Categories extends Table { class CategoryWithCount { CategoryWithCount(this.category, this.count); + // can be null, in which case we count how many entries don't have a category final Category category; final int count; // amount of entries in this category } @@ -36,7 +36,7 @@ class EntryWithCategory { final Category category; } -@UseMoor(tables: [Todos, Categories], daos: [TodosDao]) +@UseMoor(tables: [Todos, Categories]) class Database extends _$Database { Database() : super(FlutterQueryExecutor.inDatabaseFolder( @@ -46,73 +46,91 @@ class Database extends _$Database { int get schemaVersion => 1; @override - MigrationStrategy get migration => MigrationStrategy(onCreate: (Migrator m) { + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (Migrator m) { return m.createAllTables(); - }, onUpgrade: (Migrator m, int from, int to) async { + }, + onUpgrade: (Migrator m, int from, int to) async { if (from == 1) { await m.addColumn(todos, todos.targetDate); } - }); + }, + ); + } Stream> categoriesWithCount() { // select all categories and load how many associated entries there are for // each category return customSelectStream( - 'SELECT *, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount" FROM categories c;', - readsFrom: {todos, categories}) - .map((rows) { + 'SELECT c.*, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS amount' + ' FROM categories c ' + 'UNION ALL SELECT null, null, ' + '(SELECT COUNT(*) FROM todos WHERE category IS NULL)', + readsFrom: {todos, categories}, + ).map((rows) { // when we have the result set, map each row to the data class - return rows - .map((row) => CategoryWithCount( - Category.fromData(row.data, this), row.readInt('amount'))) - .toList(); + return rows.map((row) { + final hasId = row.data['id'] != null; + + return CategoryWithCount( + hasId ? Category.fromData(row.data, this) : null, + row.readInt('amount'), + ); + }).toList(); }); } - Future> entriesWithCategories() async { - final results = await select(todos).join([ - leftOuterJoin(categories, categories.id.equalsExp(todos.category)) - ]).get(); + /// Watches all entries in the given [category]. If the category is null, all + /// entries will be shown instead. + Stream> watchEntriesInCategory(Category category) { + final query = select(todos).join( + [leftOuterJoin(categories, categories.id.equalsExp(todos.category))]); - return results.map((row) { - return EntryWithCategory(row.readTable(todos), row.readTable(categories)); - }).toList(); + if (category != null) { + query.where(categories.id.equals(category.id)); + } else { + query.where(isNull(categories.id)); + } + + return query.watch().map((rows) { + // read both the entry and the associated category for each row + return rows.map((row) { + return EntryWithCategory( + row.readTable(todos), + row.readTable(categories), + ); + }).toList(); + }); } - Stream> allEntries() { - return select(todos).watch(); - } - - Future addEntry(TodoEntry entry) { + Future createEntry(TodoEntry entry) { return into(todos).insert(entry); } + /// Updates the row in the database represents this entry by writing the + /// updated data. + Future updateEntry(TodoEntry entry) { + return update(todos).replace(entry); + } + Future deleteEntry(TodoEntry entry) { return delete(todos).delete(entry); } - Future updateContent(int id, String content) { - return (update(todos)..where((t) => t.id.equals(id))) - .write(TodoEntry(content: content)); + Future createCategory(Category category) { + return into(categories).insert(category); } - Future updateDate(int id, DateTime dueDate) { - return (update(todos)..where((t) => t.id.equals(id))) - .write(TodoEntry(targetDate: dueDate)); - } - - Future testTransaction(TodoEntry entry) { - return transaction((t) { - final updatedContent = entry.copyWith( - content: entry.content.toUpperCase(), - ); - t.update(todos).replace(updatedContent); - - final updatedDate = updatedContent.copyWith( - targetDate: DateTime.now(), + Future deleteCategory(Category category) { + return transaction((t) async { + await t.customUpdate( + 'UPDATE todos SET category = NULL WHERE category = ?', + updates: {todos}, + variables: [Variable.withInt(category.id)], ); - t.update(todos).replace(updatedDate); + await t.delete(categories).delete(category); }); } } diff --git a/moor_flutter/example/lib/database/database.g.dart b/moor_flutter/example/lib/database/database.g.dart index 58855078..ad96897e 100644 --- a/moor_flutter/example/lib/database/database.g.dart +++ b/moor_flutter/example/lib/database/database.g.dart @@ -191,7 +191,7 @@ class Category { return Category( id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']), description: - stringType.mapFromDatabaseResponse(data['${effectivePrefix}`desc`']), + stringType.mapFromDatabaseResponse(data['${effectivePrefix}desc']), ); } factory Category.fromJson(Map json) { @@ -247,10 +247,10 @@ class $CategoriesTable extends Categories GeneratedTextColumn get description => _description ??= _constructDescription(); GeneratedTextColumn _constructDescription() { - var cName = '`desc`'; + var cName = 'desc'; if (_alias != null) cName = '$_alias.$cName'; return GeneratedTextColumn( - '`desc`', + 'desc', $tableName, false, ); @@ -283,7 +283,7 @@ class $CategoriesTable extends Categories map['id'] = Variable(d.id); } if (d.description != null || includeNulls) { - map['`desc`'] = Variable(d.description); + map['desc'] = Variable(d.description); } return map; } diff --git a/moor_flutter/example/lib/database/todos_dao.dart b/moor_flutter/example/lib/database/todos_dao.dart deleted file mode 100644 index ed0705fc..00000000 --- a/moor_flutter/example/lib/database/todos_dao.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:async'; - -import 'package:moor/moor.dart'; -import 'database.dart'; - -part 'todos_dao.g.dart'; - -@UseDao(tables: [Todos]) -class TodosDao extends DatabaseAccessor with _$TodosDaoMixin { - TodosDao(Database db) : super(db); - - Stream> todosInCategory(Category category) { - if (category == null) { - return (select(todos)..where((t) => isNull(t.category))).watch(); - } else { - return (select(todos)..where((t) => t.category.equals(category.id))) - .watch(); - } - } -} diff --git a/moor_flutter/example/lib/database/todos_dao.g.dart b/moor_flutter/example/lib/database/todos_dao.g.dart deleted file mode 100644 index 86c20fdd..00000000 --- a/moor_flutter/example/lib/database/todos_dao.g.dart +++ /dev/null @@ -1,11 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'todos_dao.dart'; - -// ************************************************************************** -// DaoGenerator -// ************************************************************************** - -mixin _$TodosDaoMixin on DatabaseAccessor { - $TodosTable get todos => db.todos; -} diff --git a/moor_flutter/example/lib/main.dart b/moor_flutter/example/lib/main.dart index 4280c84f..cc3d04bf 100644 --- a/moor_flutter/example/lib/main.dart +++ b/moor_flutter/example/lib/main.dart @@ -29,7 +29,13 @@ class MyAppState extends State { child: MaterialApp( title: 'moor Demo', theme: ThemeData( - primarySwatch: Colors.purple, + primarySwatch: Colors.orange, + // use the good-looking updated material text style + typography: Typography( + englishLike: Typography.englishLike2018, + dense: Typography.dense2018, + tall: Typography.tall2018, + ), ), home: HomeScreen(), ), diff --git a/moor_flutter/example/lib/widgets/add_category_dialog.dart b/moor_flutter/example/lib/widgets/add_category_dialog.dart new file mode 100644 index 00000000..295f8ed7 --- /dev/null +++ b/moor_flutter/example/lib/widgets/add_category_dialog.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:moor_example/main.dart'; + +class AddCategoryDialog extends StatefulWidget { + @override + _AddCategoryDialogState createState() => _AddCategoryDialogState(); +} + +class _AddCategoryDialogState extends State { + final TextEditingController _controller = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Add a category', + style: Theme.of(context).textTheme.title, + ), + ), + TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Name of the category', + ), + onSubmitted: (_) => _addEntry(), + ), + ButtonBar( + children: [ + FlatButton( + child: const Text('Add'), + textColor: Theme.of(context).accentColor, + onPressed: _addEntry, + ), + ], + ), + ], + ), + ), + ); + } + + void _addEntry() { + if (_controller.text.isNotEmpty) { + BlocProvider.provideBloc(context).addCategory(_controller.text); + Navigator.of(context).pop(); + } + } +} diff --git a/moor_flutter/example/lib/widgets/categories_drawer.dart b/moor_flutter/example/lib/widgets/categories_drawer.dart new file mode 100644 index 00000000..63defceb --- /dev/null +++ b/moor_flutter/example/lib/widgets/categories_drawer.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:moor_example/bloc.dart'; +import 'package:moor_example/main.dart'; +import 'package:moor_example/widgets/add_category_dialog.dart'; + +class CategoriesDrawer extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Drawer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DrawerHeader( + child: Text( + 'Todo-List Demo with moor', + style: Theme.of(context) + .textTheme + .subhead + .copyWith(color: Colors.white), + ), + decoration: BoxDecoration(color: Colors.orange), + ), + Flexible( + child: StreamBuilder>( + stream: BlocProvider.provideBloc(context).categories, + builder: (context, snapshot) { + final categories = snapshot.data ?? []; + + return ListView.builder( + itemBuilder: (context, index) { + return _CategoryDrawerEntry(entry: categories[index]); + }, + itemCount: categories.length, + ); + }, + ), + ), + Spacer(), + Row( + children: [ + FlatButton( + child: const Text('Add category'), + textColor: Theme.of(context).accentColor, + onPressed: () { + showDialog( + context: context, builder: (_) => AddCategoryDialog()); + }, + ), + ], + ), + ], + ), + ); + } +} + +class _CategoryDrawerEntry extends StatelessWidget { + final CategoryWithActiveInfo entry; + + const _CategoryDrawerEntry({Key key, this.entry}) : super(key: key); + + @override + Widget build(BuildContext context) { + final category = entry.categoryWithCount.category; + String title; + if (category == null) { + title = 'No category'; + } else { + title = category.description ?? 'Unnamed'; + } + + final isActive = entry.isActive; + final bloc = BlocProvider.provideBloc(context); + + final rowContent = [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: isActive ? Theme.of(context).accentColor : Colors.black, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text('${entry.categoryWithCount?.count} entries'), + ), + ]; + + // also show a delete button if the category can be deleted + if (category != null) { + rowContent.addAll([ + Spacer(), + IconButton( + icon: const Icon(Icons.delete_outline), + color: Colors.red, + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete'), + content: Text('Really delete category $title?'), + actions: [ + FlatButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context, false); + }, + ), + FlatButton( + child: const Text('Delete'), + textColor: Colors.red, + onPressed: () { + Navigator.pop(context, true); + }, + ), + ], + ); + }, + ); + + if (confirmed == true) { + // can be null when the dialog is dismissed + bloc.deleteCategory(category); + } + }, + ), + ]); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Material( + color: isActive + ? Colors.orangeAccent.withOpacity(0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () { + bloc.showCategory(entry.categoryWithCount.category); + Navigator.pop(context); // close the navigation drawer + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: rowContent, + ), + ), + ), + ), + ); + } +} diff --git a/moor_flutter/example/lib/widgets/homescreen.dart b/moor_flutter/example/lib/widgets/homescreen.dart index e00e1c80..04865c5d 100644 --- a/moor_flutter/example/lib/widgets/homescreen.dart +++ b/moor_flutter/example/lib/widgets/homescreen.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart' as f show Column; import 'package:moor_example/bloc.dart'; import 'package:moor_example/database/database.dart'; import 'package:moor_example/main.dart'; +import 'package:moor_example/widgets/categories_drawer.dart'; import 'package:moor_example/widgets/todo_card.dart'; import 'package:moor_flutter/moor_flutter.dart'; @@ -28,34 +29,35 @@ class HomeScreenState extends State { appBar: AppBar( title: Text('Todo list'), ), + drawer: CategoriesDrawer(), // A moorAnimatedList automatically animates incoming and leaving items, we only // have to tell it what data to display and how to turn data into widgets. - body: MoorAnimatedList( - stream: bloc - .allEntries, // we want to show an updating stream of all entries + body: MoorAnimatedList( + // we want to show an updating stream of all relevant entries + stream: bloc.homeScreenEntries, // consider items equal if their id matches. Otherwise, we'd get an // animation of an old item leaving and another one coming in every time // the content of an item changed! - equals: (a, b) => a.id == b.id, + equals: (a, b) => a.entry.id == b.entry.id, itemBuilder: (ctx, item, animation) { // When a new item arrives, it will expand vertically return SizeTransition( - key: ObjectKey(item.id), + key: ObjectKey(item.entry.id), sizeFactor: animation, axis: Axis.vertical, - child: TodoCard(item), + child: TodoCard(item.entry), ); }, removedItemBuilder: (ctx, item, animation) { // and it will leave the same way after being deleted. return SizeTransition( - key: ObjectKey(item.id), + key: ObjectKey(item.entry.id), sizeFactor: animation, axis: Axis.vertical, child: AnimatedBuilder( animation: CurvedAnimation(parent: animation, curve: Curves.easeOut), - child: TodoCard(item), + child: TodoCard(item.entry), builder: (context, child) { return Opacity( opacity: animation.value, @@ -102,7 +104,7 @@ class HomeScreenState extends State { if (controller.text.isNotEmpty) { // We write the entry here. Notice how we don't have to call setState() // or anything - moor will take care of updating the list automatically. - bloc.addEntry(TodoEntry(content: controller.text)); + bloc.createEntry(controller.text); controller.clear(); } } diff --git a/moor_flutter/example/lib/widgets/todo_card.dart b/moor_flutter/example/lib/widgets/todo_card.dart index dc91568f..70e182ba 100644 --- a/moor_flutter/example/lib/widgets/todo_card.dart +++ b/moor_flutter/example/lib/widgets/todo_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:moor_example/database/database.dart'; import 'package:moor_example/main.dart'; -import 'package:moor_example/widgets/todo_edit_dialog.dart'; import 'package:intl/intl.dart'; +import 'package:moor_example/widgets/todo_edit_dialog.dart'; final DateFormat _format = DateFormat.yMMMd(); @@ -18,7 +18,7 @@ class TodoCard extends StatelessWidget { if (entry.targetDate == null) { dueDate = GestureDetector( onTap: () { - BlocProvider.provideBloc(context).db.testTransaction(entry); + // BlocProvider.provideBloc(context).db.testTransaction(entry); }, child: const Text( 'No due date set', @@ -48,21 +48,6 @@ class TodoCard extends StatelessWidget { ], ), ), - IconButton( - icon: const Icon(Icons.calendar_today), - color: Colors.green, - onPressed: () async { - final dateTime = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime(2019), - lastDate: DateTime(3038)); - - await BlocProvider.provideBloc(context) - .db - .updateDate(entry.id, dateTime); - }, - ), IconButton( icon: const Icon(Icons.edit), color: Colors.blue, @@ -70,9 +55,7 @@ class TodoCard extends StatelessWidget { showDialog( context: context, barrierDismissible: false, - builder: (ctx) => TodoEditDialog( - entry: entry, - ), + builder: (ctx) => TodoEditDialog(entry: entry), ); }, ), @@ -82,7 +65,7 @@ class TodoCard extends StatelessWidget { onPressed: () { // We delete the entry here. Again, notice how we don't have to call setState() or // inform the parent widget. The animated list will take care of this automatically. - BlocProvider.provideBloc(context).db.deleteEntry(entry); + BlocProvider.provideBloc(context).deleteEntry(entry); }, ) ], diff --git a/moor_flutter/example/lib/widgets/todo_edit_dialog.dart b/moor_flutter/example/lib/widgets/todo_edit_dialog.dart index bda427ab..02bc499b 100644 --- a/moor_flutter/example/lib/widgets/todo_edit_dialog.dart +++ b/moor_flutter/example/lib/widgets/todo_edit_dialog.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:moor_example/database/database.dart'; +import 'package:intl/intl.dart'; import 'package:moor_example/main.dart'; +final _dateFormat = DateFormat.yMMMd(); + class TodoEditDialog extends StatefulWidget { final TodoEntry entry; @@ -13,10 +16,12 @@ class TodoEditDialog extends StatefulWidget { class _TodoEditDialogState extends State { final TextEditingController textController = TextEditingController(); + DateTime _dueDate; @override void initState() { textController.text = widget.entry.content; + _dueDate = widget.entry.targetDate; super.initState(); } @@ -28,6 +33,11 @@ class _TodoEditDialogState extends State { @override Widget build(BuildContext context) { + var formattedDate = 'No date set'; + if (_dueDate != null) { + formattedDate = _dateFormat.format(_dueDate); + } + return AlertDialog( title: const Text('Edit entry'), content: Column( @@ -40,12 +50,38 @@ class _TodoEditDialogState extends State { helperText: 'Content of entry', ), ), + Row( + children: [ + Text(formattedDate), + Spacer(), + IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: () async { + final now = DateTime.now(); + final initialDate = _dueDate ?? now; + final firstDate = + initialDate.isBefore(now) ? initialDate : now; + + final selectedDate = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: DateTime(3000), + ); + + setState(() { + if (selectedDate != null) _dueDate = selectedDate; + }); + }, + ), + ], + ), ], ), actions: [ FlatButton( child: const Text('Cancel'), - textColor: Colors.red, + textColor: Colors.black, onPressed: () { Navigator.pop(context); }, @@ -53,13 +89,13 @@ class _TodoEditDialogState extends State { FlatButton( child: const Text('Save'), onPressed: () { - final entry = widget.entry; - if (textController.text.isNotEmpty) { - BlocProvider.provideBloc(context) - .db - .updateContent(entry.id, textController.text); - } + final updatedContent = textController.text; + final entry = widget.entry.copyWith( + content: updatedContent.isNotEmpty ? updatedContent : null, + targetDate: _dueDate, + ); + BlocProvider.provideBloc(context).updateEntry(entry); Navigator.pop(context); }, ), diff --git a/moor_flutter/example/pubspec.yaml b/moor_flutter/example/pubspec.yaml index 24f026de..39b6d3b4 100644 --- a/moor_flutter/example/pubspec.yaml +++ b/moor_flutter/example/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: sdk: flutter intl: cupertino_icons: ^0.1.2 - rxdart: 0.20.0 + rxdart: 0.21.0 moor_flutter: ^1.1.0 dev_dependencies: diff --git a/moor_generator/lib/src/model/specified_column.dart b/moor_generator/lib/src/model/specified_column.dart index 29f44513..281274c6 100644 --- a/moor_generator/lib/src/model/specified_column.dart +++ b/moor_generator/lib/src/model/specified_column.dart @@ -1,5 +1,4 @@ import 'package:built_value/built_value.dart'; -import 'package:moor_generator/src/sqlite_keywords.dart' show isSqliteKeyword; part 'specified_column.g.dart'; @@ -18,14 +17,6 @@ abstract class ColumnName implements Built { ColumnName._(); - ColumnName escapeIfSqlKeyword() { - if (isSqliteKeyword(name)) { - return rebuild((b) => b.name = '`$name`'); // wrap name in backticks - } else { - return this; - } - } - factory ColumnName([updates(ColumnNameBuilder b)]) = _$ColumnName; factory ColumnName.implicitly(String name) => ColumnName((b) => b diff --git a/moor_generator/lib/src/parser/column_parser.dart b/moor_generator/lib/src/parser/column_parser.dart index 4c63a7aa..7e40737f 100644 --- a/moor_generator/lib/src/parser/column_parser.dart +++ b/moor_generator/lib/src/parser/column_parser.dart @@ -150,7 +150,7 @@ class ColumnParser extends ParserBase { return SpecifiedColumn( type: _startMethodToColumnType(foundStartMethod), dartGetterName: getter.name.name, - name: name.escapeIfSqlKeyword(), + name: name, declaredAsPrimaryKey: wasDeclaredAsPrimaryKey, customConstraints: foundCustomConstraint, nullable: nullable, diff --git a/moor_generator/lib/src/parser/table_parser.dart b/moor_generator/lib/src/parser/table_parser.dart index f1392cf9..395d2592 100644 --- a/moor_generator/lib/src/parser/table_parser.dart +++ b/moor_generator/lib/src/parser/table_parser.dart @@ -4,11 +4,11 @@ import 'package:moor_generator/src/errors.dart'; import 'package:moor_generator/src/model/specified_column.dart'; import 'package:moor_generator/src/model/specified_table.dart'; import 'package:moor_generator/src/parser/parser.dart'; -import 'package:moor_generator/src/sqlite_keywords.dart'; import 'package:moor_generator/src/utils/names.dart'; import 'package:moor_generator/src/utils/type_utils.dart'; import 'package:moor_generator/src/moor_generator.dart'; // ignore: implementation_imports import 'package:recase/recase.dart'; +import 'package:moor/sqlite_keywords.dart'; class TableParser extends ParserBase { TableParser(MoorGenerator generator) : super(generator);