Flutter - 编写高级应用程序
在本章中,我们将学习如何编写一个功能齐全的移动应用程序,expense_calculator。expense_calculator 的目的是存储我们的费用信息。应用程序的完整功能如下 −
费用清单。
用于输入新费用的表格。
用于编辑/删除现有费用的选项。
任何情况下的总费用。
我们将使用下面提到的 Flutter 框架的高级功能来编写费用计算器应用程序。
高级使用 ListView 显示费用清单。
表格编程。
SQLite 数据库编程来存储我们的费用。
scoped_model 状态管理以简化我们的编程。
让我们开始编写 expense_calculator应用程序。
在 Android Studio 中创建一个新的 Flutter 应用程序,expense_calculator。
打开 pubspec.yaml 并添加包依赖项。
dependencies: flutter: sdk: flutter sqflite: ^1.1.0 path_provider: ^0.5.0+1 scoped_model: ^1.0.1 intl: any
注意这里的这些点 −
sqflite 用于 SQLite 数据库编程。
path_provider 用于获取系统特定的应用程序路径。
scoped_model 用于状态管理。
intl 用于日期格式化。
Android Studio 将显示以下警报,表明 pubspec.yaml 已更新。
单击获取依赖项选项。 Android Studio 将从 Internet 获取该软件包,并为应用程序正确配置它。
删除 main.dart 中的现有代码。
添加新文件 Expense.dart 以创建 Expense 类。Expense 类将具有以下属性和方法。
property: id − 表示 SQLite 数据库中费用条目的唯一 ID。
property: amount − 花费金额。
property: date − 花费金额的日期。
property: category − 类别表示花费金额的区域。例如食物、旅游等,
formattedDate − 用于格式化日期属性
fromMap − 用于将数据库表中的字段映射到费用对象中的属性并创建一个新的费用对象。
factory Expense.fromMap(Map<String, dynamic> data) { return Expense( data['id'], data['amount'], DateTime.parse(data['date']), data['category'] ); }
toMap − 用于将费用对象转换为 Dart Map,可进一步用于数据库编程
Map<String, dynamic> toMap() => { "id" : id, "amount" : amount, "date" : date.toString(), "category" : category, };
columns − 用于表示数据库字段的静态变量。
在 Expense.dart 文件中输入并保存以下代码。
import 'package:intl/intl.dart'; class Expense { final int id; final double amount; final DateTime date; final String category; String get formattedDate { var formatter = new DateFormat('yyyy-MM-dd'); return formatter.format(this.date); } static final columns = ['id', 'amount', 'date', 'category']; Expense(this.id, this.amount, this.date, this.category); factory Expense.fromMap(Map<String, dynamic> data) { return Expense( data['id'], data['amount'], DateTime.parse(data['date']), data['category'] ); } Map<String, dynamic> toMap() => { "id" : id, "amount" : amount, "date" : date.toString(), "category" : category, }; }
以上代码简单易懂。
添加新文件 Database.dart 以创建 SQLiteDbProvider 类。SQLiteDbProvider 类的用途如下 −
使用 getAllExpenses 方法获取数据库中可用的所有费用。它将用于列出用户的所有费用信息。
Future<List<Expense>> getAllExpenses() async { final db = await database; List<Map> results = await db.query( "Expense", columns: Expense.columns, orderBy: "date DESC" ); List<Expense> expenses = new List(); results.forEach((result) { Expense expense = Expense.fromMap(result); expenses.add(expense); }); return expenses; }
使用 getExpenseById 方法根据数据库中可用的费用标识获取特定费用信息。它将用于向用户显示特定费用信息。
Future<Expense> getExpenseById(int id) async { final db = await database; var result = await db.query("Expense", where: "id = ", whereArgs: [id]); return result.isNotEmpty ? Expense.fromMap(result.first) : Null; }
使用 getTotalExpense 方法获取用户的总支出。它将用于向用户显示当前的总支出。
Future<double> getTotalExpense() async { final db = await database; List<Map> list = await db.rawQuery( "Select SUM(amount) as amount from expense" ); return list.isNotEmpty ? list[0]["amount"] : Null; }
使用插入方法将新的费用信息添加到数据库中。它将用于用户将新的费用条目添加到应用程序中。
Future<Expense> insert(Expense expense) async { final db = await database; var maxIdResult = await db.rawQuery( "SELECT MAX(id)+1 as last_inserted_id FROM Expense" ); var id = maxIdResult.first["last_inserted_id"]; var result = await db.rawInsert( "INSERT Into Expense (id, amount, date, category)" " VALUES (?, ?, ?, ?)", [ id, expense.amount, expense.date.toString(), expense.category ] ); return Expense(id, expense.amount, expense.date, expense.category); }
使用更新方法更新现有费用信息。它将用于编辑和更新用户在系统中可用的现有费用条目。
update(Expense product) async { final db = await database; var result = await db.update("Expense", product.toMap(), where: "id = ?", whereArgs: [product.id]); return result; }
使用 delete 方法删除现有费用信息。它将用于删除用户在系统中可用的现有费用条目。
delete(int id) async { final db = await database; db.delete("Expense", where: "id = ?", whereArgs: [id]); }
SQLiteDbProvider类完整代码如下 −
import 'dart:async'; import 'dart:io'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import 'Expense.dart'; class SQLiteDbProvider { SQLiteDbProvider._(); static final SQLiteDbProvider db = SQLiteDbProvider._(); static Database _database; Future<Database> get database async { if (_database != null) return _database; _database = await initDB(); return _database; } initDB() async { Directory documentsDirectory = await getApplicationDocumentsDirectory(); String path = join(documentsDirectory.path, "ExpenseDB2.db"); return await openDatabase( path, version: 1, onOpen:(db){}, onCreate: (Database db, int version) async { await db.execute( "CREATE TABLE Expense ( ""id INTEGER PRIMARY KEY," "amount REAL," "date TEXT," "category TEXT"" ) "); await db.execute( "INSERT INTO Expense ('id', 'amount', 'date', 'category') values (?, ?, ?, ?)",[1, 1000, '2019-04-01 10:00:00', "Food"] ); /*await db.execute( "INSERT INTO Product ('id', 'name', 'description', 'price', 'image') values (?, ?, ?, ?, ?)", [ 2, "Pixel", "Pixel is the most feature phone ever", 800, "pixel.png" ] ); await db.execute( "INSERT INTO Product ('id', 'name', 'description', 'price', 'image') values (?, ?, ?, ?, ?)", [ 3, "Laptop", "Laptop is most productive development tool", 2000, "laptop.png" ] ); await db.execute( "INSERT INTO Product ('id', 'name', 'description', 'price', 'image') values (?, ?, ?, ?, ?)", [ 4, "Tablet", "Laptop is most productive development tool", 1500, "tablet.png" ] ); await db.execute( "INSERT INTO Product ('id', 'name', 'description', 'price', 'image') values (?, ?, ?, ?, ?)", [ 5, "Pendrive", "iPhone is the stylist phone ever", 100, "pendrive.png" ] ); await db.execute( "INSERT INTO Product ('id', 'name', 'description', 'price', 'image') values (?, ?, ?, ?, ?)", [ 6, "Floppy Drive", "iPhone is the stylist phone ever", 20, "floppy.png" ] ); */ } ); } Future<List<Expense>> getAllExpenses() async { final db = await database; List<Map> results = await db.query( "Expense", columns: Expense.columns, orderBy: "date DESC" ); List<Expense> expenses = new List(); results.forEach((result) { Expense expense = Expense.fromMap(result); expenses.add(expense); }); return expenses; } Future<Expense> getExpenseById(int id) async { final db = await database; var result = await db.query("Expense", where: "id = ", whereArgs: [id]); return result.isNotEmpty ? Expense.fromMap(result.first) : Null; } Future<double> getTotalExpense() async { final db = await database; List<Map> list = await db.rawQuery( "Select SUM(amount) as amount from expense" ); return list.isNotEmpty ? list[0]["amount"] : Null; } Future<Expense> insert(Expense expense) async { final db = await database; var maxIdResult = await db.rawQuery( "SELECT MAX(id)+1 as last_inserted_id FROM Expense" ); var id = maxIdResult.first["last_inserted_id"]; var result = await db.rawInsert( "INSERT Into Expense (id, amount, date, category)" " VALUES (?, ?, ?, ?)", [ id, expense.amount, expense.date.toString(), expense.category ] ); return Expense(id, expense.amount, expense.date, expense.category); } update(Expense product) async { final db = await database; var result = await db.update( "Expense", product.toMap(), where: "id = ?", whereArgs: [product.id] ); return result; } delete(int id) async { final db = await database; db.delete("Expense", where: "id = ?", whereArgs: [id]); } }
此处,
database 是获取 SQLiteDbProvider 对象的属性。
initDB 是用于选择和打开 SQLite 数据库的方法。
创建一个新文件 ExpenseListModel.dart 来创建 ExpenseListModel。该模型的目的是将用户费用的完整信息保存在内存中,并在内存中用户费用发生变化时更新应用程序的用户界面。它基于 scoped_model 包中的 Model 类。它具有以下属性和方法 −
_items − 费用的私有列表。
items − getter for _items as UnmodifiableListView<Expense>以防止对列表进行意外或意外更改。
基于 items 变量的 totalExpense − 获取器,用于获取总费用。
double get totalExpense { double amount = 0.0; for(var i = 0; i < _items.length; i++) { amount = amount + _items[i].amount; } return amount; }
load − 用于从数据库加载完整的费用并将其放入 _items 变量中。它还会调用notifyListeners 来更新UI。
void load() { Future<List<Expense>> list = SQLiteDbProvider.db.getAllExpenses(); list.then( (dbItems) { for(var i = 0; i < dbItems.length; i++) { _items.add(dbItems[i]); } notifyListeners(); }); }
byId − 用于从 _items 变量中获取特定费用。
Expense byId(int id) { for(var i = 0; i < _items.length; i++) { if(_items[i].id == id) { return _items[i]; } } return null; }
add − 用于将新费用项目添加到 _items 变量以及数据库中。它还会调用notifyListeners 来更新 UI。
void add(Expense item) { SQLiteDbProvider.db.insert(item).then((val) { _items.add(val); notifyListeners(); }); }
Update − 用于将费用项目更新到 _items 变量以及数据库中。它还调用notifyListeners来更新UI。
void update(Expense item) { bool found = false; for(var i = 0; i < _items.length; i++) { if(_items[i].id == item.id) { _items[i] = item; found = true; SQLiteDbProvider.db.update(item); break; } } if(found) notifyListeners(); }
delete − 用于从 _items 变量以及数据库中删除现有费用项目。它还会调用notifyListeners 来更新 UI。
void delete(Expense item) { bool found = false; for(var i = 0; i < _items.length; i++) { if(_items[i].id == item.id) { found = true; SQLiteDbProvider.db.delete(item.id); _items.removeAt(i); break; } } if(found) notifyListeners(); }
ExpenseListModel 类的完整代码如下 −
import 'dart:collection'; import 'package:scoped_model/scoped_model.dart'; import 'Expense.dart'; import 'Database.dart'; class ExpenseListModel extends Model { ExpenseListModel() { this.load(); } final List<Expense> _items = []; UnmodifiableListView<Expense> get items => UnmodifiableListView(_items); /*Future<double> get totalExpense { return SQLiteDbProvider.db.getTotalExpense(); }*/ double get totalExpense { double amount = 0.0; for(var i = 0; i < _items.length; i++) { amount = amount + _items[i].amount; } return amount; } void load() { Future<List<Expense>> list = SQLiteDbProvider.db.getAllExpenses(); list.then( (dbItems) { for(var i = 0; i < dbItems.length; i++) { _items.add(dbItems[i]); } notifyListeners(); }); } Expense byId(int id) { for(var i = 0; i < _items.length; i++) { if(_items[i].id == id) { return _items[i]; } } return null; } void add(Expense item) { SQLiteDbProvider.db.insert(item).then((val) { _items.add(val); notifyListeners(); }); } void update(Expense item) { bool found = false; for(var i = 0; i < _items.length; i++) { if(_items[i].id == item.id) { _items[i] = item; found = true; SQLiteDbProvider.db.update(item); break; } } if(found) notifyListeners(); } void delete(Expense item) { bool found = false; for(var i = 0; i < _items.length; i++) { if(_items[i].id == item.id) { found = true; SQLiteDbProvider.db.delete(item.id); _items.removeAt(i); break; } } if(found) notifyListeners(); } }
打开 main.dart 文件。导入如下所示的类 −
import 'package:flutter/material.dart'; import 'package:scoped_model/scoped_model.dart'; import 'ExpenseListModel.dart'; import 'Expense.dart';
添加主函数并通过传递 ScopedModel<ExpenseListModel> 小部件调用 runApp。
void main() { final expenses = ExpenseListModel(); runApp( ScopedModel<ExpenseListModel>(model: expenses, child: MyApp(),) ); }
此处,
expenses 对象从数据库加载所有用户费用信息。此外,首次打开应用程序时,它将使用适当的表创建所需的数据库。
ScopedModel 在应用程序的整个生命周期内提供费用信息,并确保在任何情况下维护应用程序的状态。它使我们能够使用 StatelessWidget 而不是 StatefulWidget。
使用 MaterialApp 小部件创建一个简单的 MyApp。
class MyApp extends StatelessWidget { // 此小部件是您的应用程序的根。 @override Widget build(BuildContext context) { return MaterialApp( title: 'Expense', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Expense calculator'), ); } }
创建 MyHomePage 小部件,在顶部显示所有用户的费用信息以及总费用。右下角的浮动按钮将用于添加新费用。
class MyHomePage extends StatelessWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(this.title), ), body: ScopedModelDescendant<ExpenseListModel>( builder: (context, child, expenses) { return ListView.separated( itemCount: expenses.items == null ? 1 : expenses.items.length + 1, itemBuilder: (context, index) { if (index == 0) { return ListTile( title: Text("Total expenses: " + expenses.totalExpense.toString(), style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),) ); } else { index = index - 1; return Dismissible( key: Key(expenses.items[index].id.toString()), onDismissed: (direction) { expenses.delete(expenses.items[index]); Scaffold.of(context).showSnackBar( SnackBar( content: Text( "Item with id, " + expenses.items[index].id.toString() + " is dismissed" ) ) ); }, child: ListTile( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => FormPage( id: expenses.items[index].id, expenses: expenses, ) ) ); }, leading: Icon(Icons.monetization_on), trailing: Icon(Icons.keyboard_arrow_right), title: Text(expenses.items[index].category + ": " + expenses.items[index].amount.toString() + " spent on " + expenses.items[index].formattedDate, style: TextStyle(fontSize: 18, fontStyle: FontStyle.italic),)) ); } }, separatorBuilder: (context, index) { return Divider(); }, ); }, ), floatingActionButton: ScopedModelDescendant<ExpenseListModel>( builder: (context, child, expenses) { return FloatingActionButton( onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => ScopedModelDescendant<ExpenseListModel>( builder: (context, child, expenses) { return FormPage( id: 0, expenses: expenses, ); } ) ) ); // expenses.add(new Expense( // 2, 1000, DateTime.parse('2019-04-01 11:00:00'), 'Food') ); // print(expenses.items.length); }, tooltip: 'Increment', child: Icon(Icons.add), ); } ) ); } }
此处,
ScopedModelDescendant 用于将费用模型传递到 ListView 和 FloatingActionButton 小部件中。
ListView.separated 和 ListTile 小部件用于列出费用信息。
Dismissible 小部件用于使用滑动手势删除费用条目。
Navigator 用于打开费用条目的编辑界面。点击费用条目即可激活它。
创建 FormPage 小部件。FormPage 小部件的用途是添加或更新费用条目。它还处理费用条目验证。
class FormPage extends StatefulWidget { FormPage({Key key, this.id, this.expenses}) : super(key: key); final int id; final ExpenseListModel expenses; @override _FormPageState createState() => _FormPageState(id: id, expenses: expenses); } class _FormPageState extends State<FormPage> { _FormPageState({Key key, this.id, this.expenses}); final int id; final ExpenseListModel expenses; final scaffoldKey = GlobalKey<ScaffoldState>(); final formKey = GlobalKey<FormState>(); double _amount; DateTime _date; String _category; void _submit() { final form = formKey.currentState; if (form.validate()) { form.save(); if (this.id == 0) expenses.add(Expense(0, _amount, _date, _category)); else expenses.update(Expense(this.id, _amount, _date, _category)); Navigator.pop(context); } } @override Widget build(BuildContext context) { return Scaffold( key: scaffoldKey, appBar: AppBar( title: Text('Enter expense details'), ), body: Padding( padding: const EdgeInsets.all(16.0), child: Form( key: formKey, child: Column( children: [ TextFormField( style: TextStyle(fontSize: 22), decoration: const InputDecoration( icon: const Icon(Icons.monetization_on), labelText: 'Amount', labelStyle: TextStyle(fontSize: 18) ), validator: (val) { Pattern pattern = r'^[1-9]\d*(\.\d+)?$'; RegExp regex = new RegExp(pattern); if (!regex.hasMatch(val)) return 'Enter a valid number'; else return null; }, initialValue: id == 0 ? '' : expenses.byId(id).amount.toString(), onSaved: (val) => _amount = double.parse(val), ), TextFormField( style: TextStyle(fontSize: 22), decoration: const InputDecoration( icon: const Icon(Icons.calendar_today), hintText: 'Enter date', labelText: 'Date', labelStyle: TextStyle(fontSize: 18), ), validator: (val) { Pattern pattern = r'^((?:19|20)\d\d)[- /.] (0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])$'; RegExp regex = new RegExp(pattern); if (!regex.hasMatch(val)) return 'Enter a valid date'; else return null; }, onSaved: (val) => _date = DateTime.parse(val), initialValue: id == 0 ? '' : expenses.byId(id).formattedDate, keyboardType: TextInputType.datetime, ), TextFormField( style: TextStyle(fontSize: 22), decoration: const InputDecoration( icon: const Icon(Icons.category), labelText: 'Category', labelStyle: TextStyle(fontSize: 18) ), onSaved: (val) => _category = val, initialValue: id == 0 ? '' : expenses.byId(id).category.toString(), ), RaisedButton( onPressed: _submit, child: new Text('Submit'), ), ], ), ), ), ); } }
这里,
TextFormField 用于创建表单条目。
TextFormField 的 validator 属性用于与 RegEx 模式一起验证表单元素。
_submit 函数与费用对象一起使用,将费用添加或更新到数据库中。
main.dart 文件的完整代码如下 −
import 'package:flutter/material.dart'; import 'package:scoped_model/scoped_model.dart'; import 'ExpenseListModel.dart'; import 'Expense.dart'; void main() { final expenses = ExpenseListModel(); runApp( ScopedModel<ExpenseListModel>( model: expenses, child: MyApp(), ) ); } class MyApp extends StatelessWidget { // 此小部件是您的应用程序的根。 @override Widget build(BuildContext context) { return MaterialApp( title: 'Expense', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Expense calculator'), ); } } class MyHomePage extends StatelessWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(this.title), ), body: ScopedModelDescendant<ExpenseListModel>( builder: (context, child, expenses) { return ListView.separated( itemCount: expenses.items == null ? 1 : expenses.items.length + 1, itemBuilder: (context, index) { if (index == 0) { return ListTile( title: Text("Total expenses: " + expenses.totalExpense.toString(), style: TextStyle(fontSize: 24,fontWeight: FontWeight.bold),) ); } else { index = index - 1; return Dismissible( key: Key(expenses.items[index].id.toString()), onDismissed: (direction) { expenses.delete(expenses.items[index]); Scaffold.of(context).showSnackBar( SnackBar( content: Text( "Item with id, " + expenses.items[index].id.toString() + " is dismissed" ) ) ); }, child: ListTile( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => FormPage( id: expenses.items[index].id, expenses: expenses, ) )); }, leading: Icon(Icons.monetization_on), trailing: Icon(Icons.keyboard_arrow_right), title: Text(expenses.items[index].category + ": " + expenses.items[index].amount.toString() + " spent on " + expenses.items[index].formattedDate, style: TextStyle(fontSize: 18, fontStyle: FontStyle.italic),)) ); } }, separatorBuilder: (context, index) { return Divider(); }, ); }, ), floatingActionButton: ScopedModelDescendant<ExpenseListModel>( builder: (context, child, expenses) { return FloatingActionButton( onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => ScopedModelDescendant<ExpenseListModel>( builder: (context, child, expenses) { return FormPage( id: 0, expenses: expenses, ); } ) ) ); // expenses.add( new Expense( // 2, 1000, DateTime.parse('2019-04-01 11:00:00'), 'Food' ) ); // print(expenses.items.length); }, tooltip: 'Increment', child: Icon(Icons.add), ); } ) ); } } class FormPage extends StatefulWidget { FormPage({Key key, this.id, this.expenses}) : super(key: key); final int id; final ExpenseListModel expenses; @override _FormPageState createState() => _FormPageState(id: id, expenses: expenses); } class _FormPageState extends State<FormPage> { _FormPageState({Key key, this.id, this.expenses}); final int id; final ExpenseListModel expenses; final scaffoldKey = GlobalKey<ScaffoldState>(); final formKey = GlobalKey<FormState>(); double _amount; DateTime _date; String _category; void _submit() { final form = formKey.currentState; if (form.validate()) { form.save(); if (this.id == 0) expenses.add(Expense(0, _amount, _date, _category)); else expenses.update(Expense(this.id, _amount, _date, _category)); Navigator.pop(context); } } @override Widget build(BuildContext context) { return Scaffold( key: scaffoldKey, appBar: AppBar( title: Text('Enter expense details'), ), body: Padding( padding: const EdgeInsets.all(16.0), child: Form( key: formKey, child: Column( children: [ TextFormField( style: TextStyle(fontSize: 22), decoration: const InputDecoration( icon: const Icon(Icons.monetization_on), labelText: 'Amount', labelStyle: TextStyle(fontSize: 18) ), validator: (val) { Pattern pattern = r'^[1-9]\d*(\.\d+)?$'; RegExp regex = new RegExp(pattern); if (!regex.hasMatch(val)) return 'Enter a valid number'; else return null; }, initialValue: id == 0 ? '' : expenses.byId(id).amount.toString(), onSaved: (val) => _amount = double.parse(val), ), TextFormField( style: TextStyle(fontSize: 22), decoration: const InputDecoration( icon: const Icon(Icons.calendar_today), hintText: 'Enter date', labelText: 'Date', labelStyle: TextStyle(fontSize: 18), ), validator: (val) { Pattern pattern = r'^((?:19|20)\d\d)[- /.] (0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])$'; RegExp regex = new RegExp(pattern); if (!regex.hasMatch(val)) return 'Enter a valid date'; else return null; }, onSaved: (val) => _date = DateTime.parse(val), initialValue: id == 0 ? '' : expenses.byId(id).formattedDate, keyboardType: TextInputType.datetime, ), TextFormField( style: TextStyle(fontSize: 22), decoration: const InputDecoration( icon: const Icon(Icons.category), labelText: 'Category', labelStyle: TextStyle(fontSize: 18) ), onSaved: (val) => _category = val, initialValue: id == 0 ? '' : expenses.byId(id).category.toString(), ), RaisedButton( onPressed: _submit, child: new Text('Submit'), ), ], ), ), ), ); } }
现在,运行应用程序。
使用浮动按钮添加新费用。
通过点击费用条目编辑现有费用。
通过向任一方向滑动费用条目来删除现有费用。
应用程序的一些屏幕截图如下−