Improve examples app, fix many issues with joins

This commit is contained in:
Simon Binder 2019-04-01 12:27:13 +02:00
parent 50076102ac
commit d284aca4f6
No known key found for this signature in database
GPG Key ID: B807FDF954BA00CF
23 changed files with 499 additions and 169 deletions

View File

@ -1,5 +1,8 @@
/// Provides utilities around sql keywords, like optional escaping etc.
library moor.sqlite_keywords;
// https://www.sqlite.org/lang_keywords.html // https://www.sqlite.org/lang_keywords.html
const sqliteKeywords = [ const sqliteKeywords = {
'ABORT', 'ABORT',
'ACTION', 'ACTION',
'ADD', 'ADD',
@ -136,7 +139,7 @@ const sqliteKeywords = [
'WINDOW', 'WINDOW',
'WITH', 'WITH',
'WITHOUT' 'WITHOUT'
]; };
bool isSqliteKeyword(String s) => sqliteKeywords.contains(s.toUpperCase()); bool isSqliteKeyword(String s) => sqliteKeywords.contains(s.toUpperCase());

View File

@ -22,7 +22,10 @@ class InsertStatement<DataClass> {
/// thrown. An insert will also fail if another row with the same primary key /// 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 /// or unique constraints already exists. If you want to override data in that
/// case, use [insertOrReplace] instead. /// case, use [insertOrReplace] instead.
Future<void> insert(DataClass entity) async { ///
/// If the table contains an auto-increment column, the generated value will
/// be returned.
Future<int> insert(DataClass entity) async {
if (!table.validateIntegrity(entity, true)) { if (!table.validateIntegrity(entity, true)) {
throw InvalidDataException( throw InvalidDataException(
'Invalid data: $entity cannot be written into ${table.$tableName}'); 'Invalid data: $entity cannot be written into ${table.$tableName}');
@ -54,9 +57,10 @@ class InsertStatement<DataClass> {
ctx.buffer.write(')'); ctx.buffer.write(')');
await database.executor.doWhenOpened((e) async { return await database.executor.doWhenOpened((e) async {
await database.executor.runInsert(ctx.sql, ctx.boundVariables); final id = await database.executor.runInsert(ctx.sql, ctx.boundVariables);
database.markTablesUpdated({table}); database.markTablesUpdated({table});
return id;
}); });
} }

View File

@ -4,8 +4,10 @@ import 'package:meta/meta.dart';
import 'package:moor/moor.dart'; import 'package:moor/moor.dart';
import 'package:moor/src/runtime/components/component.dart'; import 'package:moor/src/runtime/components/component.dart';
import 'package:moor/src/runtime/components/join.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/database.dart';
import 'package:moor/src/runtime/executor/stream_queries.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/statements/query.dart';
import 'package:moor/src/runtime/structure/table_info.dart'; import 'package:moor/src/runtime/structure/table_info.dart';
@ -38,7 +40,17 @@ class JoinedSelectStatement<FirstT, FirstD> extends Query<FirstT, FirstD>
ctx.buffer.write(', '); 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); column.writeInto(ctx);
ctx.buffer.write(' AS "');
column.writeInto(ctx, ignoreEscape: true);
ctx.buffer.write('"');
isFirst = false; isFirst = false;
} }
@ -57,9 +69,36 @@ class JoinedSelectStatement<FirstT, FirstD> extends Query<FirstT, FirstD>
} }
} }
void where(Expression<bool, BoolType> predicate) {
if (whereExpr == null) {
whereExpr = Where(predicate);
} else {
whereExpr = Where(and(whereExpr.predicate, predicate));
}
}
void orderBy(List<OrderingTerm> terms) {
orderByExpr = OrderBy(terms);
}
Stream<List<TypedResult>> watch() {
final ctx = constructQuery();
final fetcher = QueryStreamFetcher<List<TypedResult>>(
readsFrom: watchedTables,
fetchData: () => _getWithQuery(ctx),
key: StreamKey(ctx.sql, ctx.boundVariables, TypedResult),
);
return database.createStream(fetcher);
}
/// Executes this statement and returns the result. /// Executes this statement and returns the result.
Future<List<TypedResult>> get() async { Future<List<TypedResult>> get() async {
final ctx = constructQuery(); final ctx = constructQuery();
return _getWithQuery(ctx);
}
Future<List<TypedResult>> _getWithQuery(GenerationContext ctx) async {
final results = await ctx.database.executor.doWhenOpened((e) async { final results = await ctx.database.executor.doWhenOpened((e) async {
return await e.runSelect(ctx.sql, ctx.boundVariables); return await e.runSelect(ctx.sql, ctx.boundVariables);
}); });

View File

@ -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/text.dart';
import 'package:moor/src/runtime/expressions/variables.dart'; import 'package:moor/src/runtime/expressions/variables.dart';
import 'package:moor/src/types/sql_types.dart'; import 'package:moor/src/types/sql_types.dart';
import 'package:moor/sqlite_keywords.dart';
/// Base class for the implementation of [Column]. /// Base class for the implementation of [Column].
abstract class GeneratedColumn<T, S extends SqlType<T>> extends Column<T, S> { abstract class GeneratedColumn<T, S extends SqlType<T>> extends Column<T, S> {
/// The sql name of this column. /// The sql name of this column.
final String $name; final String $name;
String get escapedName => escapeIfNeeded($name);
/// The name of the table that contains this column /// The name of the table that contains this column
final String tableName; final String tableName;
@ -31,7 +34,7 @@ abstract class GeneratedColumn<T, S extends SqlType<T>> extends Column<T, S> {
/// [here](https://www.sqlite.org/syntax/column-def.html), into the given /// [here](https://www.sqlite.org/syntax/column-def.html), into the given
/// buffer. /// buffer.
void writeColumnDefinition(StringBuffer into) { void writeColumnDefinition(StringBuffer into) {
into.write('${$name} $typeName '); into.write('$escapedName $typeName ');
if ($customConstraints == null) { if ($customConstraints == null) {
into.write($nullable ? 'NULL' : 'NOT NULL'); into.write($nullable ? 'NULL' : 'NOT NULL');
@ -50,11 +53,11 @@ abstract class GeneratedColumn<T, S extends SqlType<T>> extends Column<T, S> {
String get typeName; String get typeName;
@override @override
void writeInto(GenerationContext context) { void writeInto(GenerationContext context, {bool ignoreEscape = false}) {
if (context.hasMultipleTables) { if (context.hasMultipleTables) {
context.buffer..write(tableName)..write('.'); 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 /// Checks whether the given value fits into this column. The default

View File

@ -221,7 +221,7 @@ class Category {
return Category( return Category(
id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']), id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']),
description: description:
stringType.mapFromDatabaseResponse(data['${effectivePrefix}`desc`']), stringType.mapFromDatabaseResponse(data['${effectivePrefix}desc']),
); );
} }
factory Category.fromJson(Map<String, dynamic> json) { factory Category.fromJson(Map<String, dynamic> json) {
@ -277,9 +277,9 @@ class $CategoriesTable extends Categories
GeneratedTextColumn get description => GeneratedTextColumn get description =>
_description ??= _constructDescription(); _description ??= _constructDescription();
GeneratedTextColumn _constructDescription() { GeneratedTextColumn _constructDescription() {
var cName = '`desc`'; var cName = 'desc';
if (_alias != null) cName = '$_alias.$cName'; if (_alias != null) cName = '$_alias.$cName';
return GeneratedTextColumn('`desc`', $tableName, false, return GeneratedTextColumn('desc', $tableName, false,
$customConstraints: 'NOT NULL UNIQUE'); $customConstraints: 'NOT NULL UNIQUE');
} }
@ -310,7 +310,7 @@ class $CategoriesTable extends Categories
map['id'] = Variable<int, IntType>(d.id); map['id'] = Variable<int, IntType>(d.id);
} }
if (d.description != null || includeNulls) { if (d.description != null || includeNulls) {
map['`desc`'] = Variable<String, StringType>(d.description); map['desc'] = Variable<String, StringType>(d.description);
} }
return map; return map;
} }

View File

@ -9,14 +9,14 @@ typedef Expression<int, IntType> _Extractor(
/// Tests the top level [year], [month], ..., [second] methods /// Tests the top level [year], [month], ..., [second] methods
void main() { void main() {
final expectedResults = <_Extractor, String>{ final expectedResults = <_Extractor, String>{
year: 'CAST(strftime("%Y", column, "unixepoch") AS INTEGER)', year: 'CAST(strftime("%Y", val, "unixepoch") AS INTEGER)',
month: 'CAST(strftime("%m", column, "unixepoch") AS INTEGER)', month: 'CAST(strftime("%m", val, "unixepoch") AS INTEGER)',
day: 'CAST(strftime("%d", column, "unixepoch") AS INTEGER)', day: 'CAST(strftime("%d", val, "unixepoch") AS INTEGER)',
hour: 'CAST(strftime("%H", column, "unixepoch") AS INTEGER)', hour: 'CAST(strftime("%H", val, "unixepoch") AS INTEGER)',
minute: 'CAST(strftime("%M", column, "unixepoch") AS INTEGER)', minute: 'CAST(strftime("%M", val, "unixepoch") AS INTEGER)',
second: 'CAST(strftime("%S", column, "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) { expectedResults.forEach((key, value) {
test('should extract field', () { test('should extract field', () {

View File

@ -55,4 +55,11 @@ void main() {
throwsA(const TypeMatcher<InvalidDataException>()), throwsA(const TypeMatcher<InvalidDataException>()),
); );
}); });
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));
});
} }

View File

@ -13,19 +13,6 @@ void main() {
}); });
test('generates join statements', () async { 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 todos = db.alias(db.todosTable, 't');
final categories = db.alias(db.categories, 'c'); final categories = db.alias(db.categories, 'c');
@ -34,9 +21,10 @@ void main() {
]).get(); ]).get();
verify(executor.runSelect( verify(executor.runSelect(
'SELECT t.id, t.title, t.content, t.target_date, ' 'SELECT t.id AS "t.id", t.title AS "t.title", t.content AS "t.content", '
't.category, c.id, c.`desc` FROM todos t ' 't.target_date AS "t.target_date", '
'LEFT OUTER JOIN categories c ON c.id = t.category;', '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))); argThat(isEmpty)));
}); });
@ -54,7 +42,7 @@ void main() {
't.target_date': date.millisecondsSinceEpoch ~/ 1000, 't.target_date': date.millisecondsSinceEpoch ~/ 1000,
't.category': 3, 't.category': 3,
'c.id': 3, 'c.id': 3,
'c.`desc`': 'description', 'c.desc': 'description',
} }
]); ]);
}); });

View File

@ -1,14 +1,86 @@
import 'dart:async';
import 'package:moor_example/database/database.dart'; 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 { class TodoAppBloc {
final Database db; final Database db;
Stream<List<TodoEntry>> get allEntries => db.allEntries();
TodoAppBloc() : db = Database(); // the category that is selected at the moment. null means that we show all
// entries
final BehaviorSubject<Category> _activeCategory =
BehaviorSubject.seeded(null);
void addEntry(TodoEntry entry) { Observable<List<EntryWithCategory>> _currentEntries;
db.addEntry(entry);
/// A stream of entries that should be displayed on the home screen.
Observable<List<EntryWithCategory>> get homeScreenEntries => _currentEntries;
final BehaviorSubject<List<CategoryWithActiveInfo>> _allCategories =
BehaviorSubject();
Observable<List<CategoryWithActiveInfo>> 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<List<CategoryWithCount>, Category,
List<CategoryWithActiveInfo>>(
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);
} }
} }

View File

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'package:moor_example/database/todos_dao.dart';
import 'package:moor_flutter/moor_flutter.dart'; import 'package:moor_flutter/moor_flutter.dart';
part 'database.g.dart'; part 'database.g.dart';
@ -25,6 +24,7 @@ class Categories extends Table {
class CategoryWithCount { class CategoryWithCount {
CategoryWithCount(this.category, this.count); 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 Category category;
final int count; // amount of entries in this category final int count; // amount of entries in this category
} }
@ -36,7 +36,7 @@ class EntryWithCategory {
final Category category; final Category category;
} }
@UseMoor(tables: [Todos, Categories], daos: [TodosDao]) @UseMoor(tables: [Todos, Categories])
class Database extends _$Database { class Database extends _$Database {
Database() Database()
: super(FlutterQueryExecutor.inDatabaseFolder( : super(FlutterQueryExecutor.inDatabaseFolder(
@ -46,73 +46,91 @@ class Database extends _$Database {
int get schemaVersion => 1; int get schemaVersion => 1;
@override @override
MigrationStrategy get migration => MigrationStrategy(onCreate: (Migrator m) { MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) {
return m.createAllTables(); return m.createAllTables();
}, onUpgrade: (Migrator m, int from, int to) async { },
onUpgrade: (Migrator m, int from, int to) async {
if (from == 1) { if (from == 1) {
await m.addColumn(todos, todos.targetDate); await m.addColumn(todos, todos.targetDate);
} }
}); },
);
}
Stream<List<CategoryWithCount>> categoriesWithCount() { Stream<List<CategoryWithCount>> categoriesWithCount() {
// select all categories and load how many associated entries there are for // select all categories and load how many associated entries there are for
// each category // each category
return customSelectStream( return customSelectStream(
'SELECT *, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount" FROM categories c;', 'SELECT c.*, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS amount'
readsFrom: {todos, categories}) ' FROM categories c '
.map((rows) { '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 // when we have the result set, map each row to the data class
return rows return rows.map((row) {
.map((row) => CategoryWithCount( final hasId = row.data['id'] != null;
Category.fromData(row.data, this), row.readInt('amount')))
.toList(); return CategoryWithCount(
hasId ? Category.fromData(row.data, this) : null,
row.readInt('amount'),
);
}).toList();
}); });
} }
Future<List<EntryWithCategory>> entriesWithCategories() async { /// Watches all entries in the given [category]. If the category is null, all
final results = await select(todos).join([ /// entries will be shown instead.
leftOuterJoin(categories, categories.id.equalsExp(todos.category)) Stream<List<EntryWithCategory>> watchEntriesInCategory(Category category) {
]).get(); final query = select(todos).join(
[leftOuterJoin(categories, categories.id.equalsExp(todos.category))]);
return results.map((row) { if (category != null) {
return EntryWithCategory(row.readTable(todos), row.readTable(categories)); query.where(categories.id.equals(category.id));
}).toList(); } 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<List<TodoEntry>> allEntries() { Future createEntry(TodoEntry entry) {
return select(todos).watch();
}
Future addEntry(TodoEntry entry) {
return into(todos).insert(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) { Future deleteEntry(TodoEntry entry) {
return delete(todos).delete(entry); return delete(todos).delete(entry);
} }
Future updateContent(int id, String content) { Future<int> createCategory(Category category) {
return (update(todos)..where((t) => t.id.equals(id))) return into(categories).insert(category);
.write(TodoEntry(content: content));
} }
Future updateDate(int id, DateTime dueDate) { Future deleteCategory(Category category) {
return (update(todos)..where((t) => t.id.equals(id))) return transaction((t) async {
.write(TodoEntry(targetDate: dueDate)); await t.customUpdate(
} 'UPDATE todos SET category = NULL WHERE category = ?',
updates: {todos},
Future testTransaction(TodoEntry entry) { variables: [Variable.withInt(category.id)],
return transaction((t) {
final updatedContent = entry.copyWith(
content: entry.content.toUpperCase(),
);
t.update(todos).replace(updatedContent);
final updatedDate = updatedContent.copyWith(
targetDate: DateTime.now(),
); );
t.update(todos).replace(updatedDate); await t.delete(categories).delete(category);
}); });
} }
} }

View File

@ -191,7 +191,7 @@ class Category {
return Category( return Category(
id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']), id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']),
description: description:
stringType.mapFromDatabaseResponse(data['${effectivePrefix}`desc`']), stringType.mapFromDatabaseResponse(data['${effectivePrefix}desc']),
); );
} }
factory Category.fromJson(Map<String, dynamic> json) { factory Category.fromJson(Map<String, dynamic> json) {
@ -247,10 +247,10 @@ class $CategoriesTable extends Categories
GeneratedTextColumn get description => GeneratedTextColumn get description =>
_description ??= _constructDescription(); _description ??= _constructDescription();
GeneratedTextColumn _constructDescription() { GeneratedTextColumn _constructDescription() {
var cName = '`desc`'; var cName = 'desc';
if (_alias != null) cName = '$_alias.$cName'; if (_alias != null) cName = '$_alias.$cName';
return GeneratedTextColumn( return GeneratedTextColumn(
'`desc`', 'desc',
$tableName, $tableName,
false, false,
); );
@ -283,7 +283,7 @@ class $CategoriesTable extends Categories
map['id'] = Variable<int, IntType>(d.id); map['id'] = Variable<int, IntType>(d.id);
} }
if (d.description != null || includeNulls) { if (d.description != null || includeNulls) {
map['`desc`'] = Variable<String, StringType>(d.description); map['desc'] = Variable<String, StringType>(d.description);
} }
return map; return map;
} }

View File

@ -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<Database> with _$TodosDaoMixin {
TodosDao(Database db) : super(db);
Stream<List<TodoEntry>> 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();
}
}
}

View File

@ -1,11 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'todos_dao.dart';
// **************************************************************************
// DaoGenerator
// **************************************************************************
mixin _$TodosDaoMixin on DatabaseAccessor<Database> {
$TodosTable get todos => db.todos;
}

View File

@ -29,7 +29,13 @@ class MyAppState extends State<MyApp> {
child: MaterialApp( child: MaterialApp(
title: 'moor Demo', title: 'moor Demo',
theme: ThemeData( 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(), home: HomeScreen(),
), ),

View File

@ -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<AddCategoryDialog> {
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();
}
}
}

View File

@ -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: <Widget>[
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<List<CategoryWithActiveInfo>>(
stream: BlocProvider.provideBloc(context).categories,
builder: (context, snapshot) {
final categories = snapshot.data ?? <CategoryWithActiveInfo>[];
return ListView.builder(
itemBuilder: (context, index) {
return _CategoryDrawerEntry(entry: categories[index]);
},
itemCount: categories.length,
);
},
),
),
Spacer(),
Row(
children: <Widget>[
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<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete'),
content: Text('Really delete category $title?'),
actions: <Widget>[
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,
),
),
),
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart' as f show Column;
import 'package:moor_example/bloc.dart'; import 'package:moor_example/bloc.dart';
import 'package:moor_example/database/database.dart'; import 'package:moor_example/database/database.dart';
import 'package:moor_example/main.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_example/widgets/todo_card.dart';
import 'package:moor_flutter/moor_flutter.dart'; import 'package:moor_flutter/moor_flutter.dart';
@ -28,34 +29,35 @@ class HomeScreenState extends State<HomeScreen> {
appBar: AppBar( appBar: AppBar(
title: Text('Todo list'), title: Text('Todo list'),
), ),
drawer: CategoriesDrawer(),
// A moorAnimatedList automatically animates incoming and leaving items, we only // 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. // have to tell it what data to display and how to turn data into widgets.
body: MoorAnimatedList<TodoEntry>( body: MoorAnimatedList<EntryWithCategory>(
stream: bloc // we want to show an updating stream of all relevant entries
.allEntries, // we want to show an updating stream of all entries stream: bloc.homeScreenEntries,
// consider items equal if their id matches. Otherwise, we'd get an // 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 // animation of an old item leaving and another one coming in every time
// the content of an item changed! // 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) { itemBuilder: (ctx, item, animation) {
// When a new item arrives, it will expand vertically // When a new item arrives, it will expand vertically
return SizeTransition( return SizeTransition(
key: ObjectKey(item.id), key: ObjectKey(item.entry.id),
sizeFactor: animation, sizeFactor: animation,
axis: Axis.vertical, axis: Axis.vertical,
child: TodoCard(item), child: TodoCard(item.entry),
); );
}, },
removedItemBuilder: (ctx, item, animation) { removedItemBuilder: (ctx, item, animation) {
// and it will leave the same way after being deleted. // and it will leave the same way after being deleted.
return SizeTransition( return SizeTransition(
key: ObjectKey(item.id), key: ObjectKey(item.entry.id),
sizeFactor: animation, sizeFactor: animation,
axis: Axis.vertical, axis: Axis.vertical,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: animation:
CurvedAnimation(parent: animation, curve: Curves.easeOut), CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: TodoCard(item), child: TodoCard(item.entry),
builder: (context, child) { builder: (context, child) {
return Opacity( return Opacity(
opacity: animation.value, opacity: animation.value,
@ -102,7 +104,7 @@ class HomeScreenState extends State<HomeScreen> {
if (controller.text.isNotEmpty) { if (controller.text.isNotEmpty) {
// We write the entry here. Notice how we don't have to call setState() // 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. // or anything - moor will take care of updating the list automatically.
bloc.addEntry(TodoEntry(content: controller.text)); bloc.createEntry(controller.text);
controller.clear(); controller.clear();
} }
} }

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moor_example/database/database.dart'; import 'package:moor_example/database/database.dart';
import 'package:moor_example/main.dart'; import 'package:moor_example/main.dart';
import 'package:moor_example/widgets/todo_edit_dialog.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:moor_example/widgets/todo_edit_dialog.dart';
final DateFormat _format = DateFormat.yMMMd(); final DateFormat _format = DateFormat.yMMMd();
@ -18,7 +18,7 @@ class TodoCard extends StatelessWidget {
if (entry.targetDate == null) { if (entry.targetDate == null) {
dueDate = GestureDetector( dueDate = GestureDetector(
onTap: () { onTap: () {
BlocProvider.provideBloc(context).db.testTransaction(entry); // BlocProvider.provideBloc(context).db.testTransaction(entry);
}, },
child: const Text( child: const Text(
'No due date set', '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( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
color: Colors.blue, color: Colors.blue,
@ -70,9 +55,7 @@ class TodoCard extends StatelessWidget {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (ctx) => TodoEditDialog( builder: (ctx) => TodoEditDialog(entry: entry),
entry: entry,
),
); );
}, },
), ),
@ -82,7 +65,7 @@ class TodoCard extends StatelessWidget {
onPressed: () { onPressed: () {
// We delete the entry here. Again, notice how we don't have to call setState() or // 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. // inform the parent widget. The animated list will take care of this automatically.
BlocProvider.provideBloc(context).db.deleteEntry(entry); BlocProvider.provideBloc(context).deleteEntry(entry);
}, },
) )
], ],

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moor_example/database/database.dart'; import 'package:moor_example/database/database.dart';
import 'package:intl/intl.dart';
import 'package:moor_example/main.dart'; import 'package:moor_example/main.dart';
final _dateFormat = DateFormat.yMMMd();
class TodoEditDialog extends StatefulWidget { class TodoEditDialog extends StatefulWidget {
final TodoEntry entry; final TodoEntry entry;
@ -13,10 +16,12 @@ class TodoEditDialog extends StatefulWidget {
class _TodoEditDialogState extends State<TodoEditDialog> { class _TodoEditDialogState extends State<TodoEditDialog> {
final TextEditingController textController = TextEditingController(); final TextEditingController textController = TextEditingController();
DateTime _dueDate;
@override @override
void initState() { void initState() {
textController.text = widget.entry.content; textController.text = widget.entry.content;
_dueDate = widget.entry.targetDate;
super.initState(); super.initState();
} }
@ -28,6 +33,11 @@ class _TodoEditDialogState extends State<TodoEditDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var formattedDate = 'No date set';
if (_dueDate != null) {
formattedDate = _dateFormat.format(_dueDate);
}
return AlertDialog( return AlertDialog(
title: const Text('Edit entry'), title: const Text('Edit entry'),
content: Column( content: Column(
@ -40,12 +50,38 @@ class _TodoEditDialogState extends State<TodoEditDialog> {
helperText: 'Content of entry', helperText: 'Content of entry',
), ),
), ),
Row(
children: <Widget>[
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: [ actions: [
FlatButton( FlatButton(
child: const Text('Cancel'), child: const Text('Cancel'),
textColor: Colors.red, textColor: Colors.black,
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
}, },
@ -53,13 +89,13 @@ class _TodoEditDialogState extends State<TodoEditDialog> {
FlatButton( FlatButton(
child: const Text('Save'), child: const Text('Save'),
onPressed: () { onPressed: () {
final entry = widget.entry; final updatedContent = textController.text;
if (textController.text.isNotEmpty) { final entry = widget.entry.copyWith(
BlocProvider.provideBloc(context) content: updatedContent.isNotEmpty ? updatedContent : null,
.db targetDate: _dueDate,
.updateContent(entry.id, textController.text); );
}
BlocProvider.provideBloc(context).updateEntry(entry);
Navigator.pop(context); Navigator.pop(context);
}, },
), ),

View File

@ -11,7 +11,7 @@ dependencies:
sdk: flutter sdk: flutter
intl: intl:
cupertino_icons: ^0.1.2 cupertino_icons: ^0.1.2
rxdart: 0.20.0 rxdart: 0.21.0
moor_flutter: ^1.1.0 moor_flutter: ^1.1.0
dev_dependencies: dev_dependencies:

View File

@ -1,5 +1,4 @@
import 'package:built_value/built_value.dart'; import 'package:built_value/built_value.dart';
import 'package:moor_generator/src/sqlite_keywords.dart' show isSqliteKeyword;
part 'specified_column.g.dart'; part 'specified_column.g.dart';
@ -18,14 +17,6 @@ abstract class ColumnName implements Built<ColumnName, ColumnNameBuilder> {
ColumnName._(); 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([updates(ColumnNameBuilder b)]) = _$ColumnName;
factory ColumnName.implicitly(String name) => ColumnName((b) => b factory ColumnName.implicitly(String name) => ColumnName((b) => b

View File

@ -150,7 +150,7 @@ class ColumnParser extends ParserBase {
return SpecifiedColumn( return SpecifiedColumn(
type: _startMethodToColumnType(foundStartMethod), type: _startMethodToColumnType(foundStartMethod),
dartGetterName: getter.name.name, dartGetterName: getter.name.name,
name: name.escapeIfSqlKeyword(), name: name,
declaredAsPrimaryKey: wasDeclaredAsPrimaryKey, declaredAsPrimaryKey: wasDeclaredAsPrimaryKey,
customConstraints: foundCustomConstraint, customConstraints: foundCustomConstraint,
nullable: nullable, nullable: nullable,

View File

@ -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_column.dart';
import 'package:moor_generator/src/model/specified_table.dart'; import 'package:moor_generator/src/model/specified_table.dart';
import 'package:moor_generator/src/parser/parser.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/names.dart';
import 'package:moor_generator/src/utils/type_utils.dart'; import 'package:moor_generator/src/utils/type_utils.dart';
import 'package:moor_generator/src/moor_generator.dart'; // ignore: implementation_imports import 'package:moor_generator/src/moor_generator.dart'; // ignore: implementation_imports
import 'package:recase/recase.dart'; import 'package:recase/recase.dart';
import 'package:moor/sqlite_keywords.dart';
class TableParser extends ParserBase { class TableParser extends ParserBase {
TableParser(MoorGenerator generator) : super(generator); TableParser(MoorGenerator generator) : super(generator);