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
const sqliteKeywords = [
const sqliteKeywords = {
'ABORT',
'ACTION',
'ADD',
@ -136,7 +139,7 @@ const sqliteKeywords = [
'WINDOW',
'WITH',
'WITHOUT'
];
};
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
/// or unique constraints already exists. If you want to override data in that
/// 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)) {
throw InvalidDataException(
'Invalid data: $entity cannot be written into ${table.$tableName}');
@ -54,9 +57,10 @@ class InsertStatement<DataClass> {
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;
});
}

View File

@ -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<FirstT, FirstD> extends Query<FirstT, FirstD>
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<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.
Future<List<TypedResult>> get() async {
final ctx = constructQuery();
return _getWithQuery(ctx);
}
Future<List<TypedResult>> _getWithQuery(GenerationContext ctx) async {
final results = await ctx.database.executor.doWhenOpened((e) async {
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/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<T, S extends SqlType<T>> extends Column<T, S> {
/// 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<T, S extends SqlType<T>> extends Column<T, S> {
/// [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<T, S extends SqlType<T>> extends Column<T, S> {
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

View File

@ -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<String, dynamic> 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<int, IntType>(d.id);
}
if (d.description != null || includeNulls) {
map['`desc`'] = Variable<String, StringType>(d.description);
map['desc'] = Variable<String, StringType>(d.description);
}
return map;
}

View File

@ -9,14 +9,14 @@ typedef Expression<int, IntType> _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', () {

View File

@ -55,4 +55,11 @@ void main() {
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 {
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',
}
]);
});

View File

@ -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<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) {
db.addEntry(entry);
Observable<List<EntryWithCategory>> _currentEntries;
/// 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 '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<List<CategoryWithCount>> 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<List<EntryWithCategory>> 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<List<EntryWithCategory>> 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<List<TodoEntry>> 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<int> 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);
});
}
}

View File

@ -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<String, dynamic> 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<int, IntType>(d.id);
}
if (d.description != null || includeNulls) {
map['`desc`'] = Variable<String, StringType>(d.description);
map['desc'] = Variable<String, StringType>(d.description);
}
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(
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(),
),

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/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<HomeScreen> {
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<TodoEntry>(
stream: bloc
.allEntries, // we want to show an updating stream of all entries
body: MoorAnimatedList<EntryWithCategory>(
// 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<HomeScreen> {
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();
}
}

View File

@ -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);
},
)
],

View File

@ -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<TodoEditDialog> {
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<TodoEditDialog> {
@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<TodoEditDialog> {
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: [
FlatButton(
child: const Text('Cancel'),
textColor: Colors.red,
textColor: Colors.black,
onPressed: () {
Navigator.pop(context);
},
@ -53,13 +89,13 @@ class _TodoEditDialogState extends State<TodoEditDialog> {
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);
},
),

View File

@ -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:

View File

@ -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, ColumnNameBuilder> {
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

View File

@ -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,

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_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);