mirror of https://github.com/AMT-Cheif/drift.git
Improve examples app, fix many issues with joins
This commit is contained in:
parent
50076102ac
commit
d284aca4f6
|
@ -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());
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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', () {
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(),
|
||||
),
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
)
|
||||
],
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue