费用跟踪应用采用了Wijmo5和Ionic Framework创建,目的是构建一个hybird app。

我们基于《Mobile first! Wijmo 5 + Ionic Framework之:Hello World!》的环境,将在本教程中完成费用跟踪App的构建。下面的代码结构是本教程完成要达到的效果,请预先创建好文件和目录。

www/-->工程根目录index.html-->app布局文件(主HTML文件)css/-->css目录js/-->javascript目录app.js-->主模块app.routes.js-->主路由模块controllers/-->app控制器目录models/-->app模块目录services/-->app数据Service目录templates/-->angularJs视图代码目录(通过UI-Router调用)lib/-->第三方类库,包括Ionic,Wijmo,jQuery等数据模型(Data Model)

在费用跟踪App中,我们先要创建Data Model,E-R图如下

Category:开支分类

Expense:开支记录

Budget: 预算(下面会用到)

在代码中,我们需要在www/js/services构建AngularJs Services来对数据模型进行建模。我们会用到HTML5的localStorage进行数据本地存储, 采用的格式为JSON。 需要注意的是,HTML5本地存储只能存字符串,任何格式存储的时候都会被自动转为字符串,所以读取的时候,需要自己进行类型的转换。目前我们实现的是HTML5 本地存储,有兴趣的读者还可移植为RESTful API、SQLite等数据存储方法。

运行demo后,通过Chrome调试查看的本地存储截图:

浏览开支历史记录

在开支历史页面中,提供了2个功能:浏览开支历史记录、删除开支记录。为了实现这些功能,在www\js\controllers\history.js文件中,添加如下代码:

//从localStorage获得开支数据$scope.expenses=ExpenseSvc.getExpensesWithCategory();

这行代码提供了返回本地存储的开支记录。ExpenseSvc 服务,不仅返回了开支对象,同时也返回了开支分类。基于这些数据,在

www\templates\history.tpl.htm文件中,在ion-context指令内添加Ionic的ion-list指令,代码如下:

<ion-viewtitle="History"><ion-nav-buttonsside="right"><aclass="buttonbutton-iconiconion-plus"href="#/app/create"></a></ion-nav-buttons><ion-contentclass="has-header"><ion-list><ion-itemng-repeat="expenseinexpenses|orderBy:'date':reversetrackbyexpense.id"class="itemitem-icon-left"><iclass="iconion-record{{expense.category.cssClass}}"></i><divclass="row"><divclass="col-50"><h3>{{expense.title}}</h3></div><divclass="col-25"><smallclass="light-grey">{{expense.date|date:'shortDate'}}</small></div><divclass="col-25">{{expense.amount|currency}}</div></div></ion-item></ion-list></ion-content></ion-view>

ion-list指令,用于生成排序的HTML列表,其子标签ion-item指令用于生成HTML列表项。 在ngRepeat指令中,我们使用了“track by”,目的是在对开支集合修改时提升性能,相关教程可参考博客《Using Track-By With ngRepeat In AngularJS 1.2 》。

现在添加删除开支记录按钮,用于向左滑动出现删除按钮、点击删除可删除开支记录。

在ion-item标签关闭前添加ion-option-button标签,代码如下:

<ion-option-buttonclass="buttonbutton-assertive"on-tap="confirmDelete(expense.id)">Delete</ion-option-button>

ion-option-button 是Ionic提供的另一个指令,用于在ion-item指令内试用。默认的,ion-option-button 是隐藏的,当在ion-item内向左滑动,则按钮会可见。这个功能尤其对小屏幕设备非常重要。另外,还可通过该指令内置的can-swipe来实现对这个权限的管理--如有的用户不允许删除操作权限。

在删除函数中(控制器),可看到代码片段如下:

functionconfirmDelete(expenseId){//deleteexpensebyitsidproperty$scope.expenses=ExpenseSvc.deleteExpense(expenseId);}

通过这个代码,我们调用ExpenseSvc服务的deleteExpense进行删除指定的开支记录(expenseId),同时这个方法也会返回开支记录集合用于更新页面数据。在真实的场景中,删除记录返回整个集合不是最理想的,但在此处我们用于演示说明。可动手试着删除几行数据试试。

另外,在删除这种比较危险的操作中,应该需要添加对话框再次提醒一下用户。这里我们使用了Ionic提供的$ionicActionSheet service服务来实现。更新www\js\controllers\history.js控制器代码的confirmDelete函数如下:

//删除开支记录$scope.confirmDelete=function(expenseId){//ionic的确认对话框//show()函数返回了一个函数,用于隐藏actionSheetvarhideSheet=$ionicActionSheet.show({titleText:'Areyousurethatyou\'dliketodeletethisexpense?',cancelText:'Cancel',destructiveText:'Delete',cancel:function(){//如果用户选择cancel,则会隐藏删除按钮$ionicListDelegate.closeOptionButtons();},destructiveButtonClicked:function(){//通过id删除开支记录$scope.expenses=ExpenseSvc.deleteExpense(expenseId);//隐藏对话框hideSheet();}});};

ionicActionSheet服务提供了自定义接口,可实现各种提示对话框。上面代码实现的提示对话框效果截图如下:

创建开支记录

点击History页面右上角的可实现手工创建一条新的开支记录。在www\templates\createExpense.tpl.htm文件中,代码如下:

<ion-viewtitle="Create"><ion-contentclass="has-headerpadding"><formname="frmCreate"><divclass="custom-form-listlist"><labelclass="itemitem-input"><iclass="iconion-alert-circledplaceholder-iconassertive"ng-show="!frmCreate.title.$pristine&&frmCreate.title.$invalid"></i><inputname="title"type="text"placeholder="Title"ng-model="expense.title"ng-maxlength="55"required></label><wj-input-numbervalue="expense.amount"min="0"step="5"format="c2"></wj-input-number><wj-calendarvalue="expense.date"></wj-calendar><wj-combo-boxitems-source="categories"display-member-path="htmlContent"selected-value-path="id"selected-value="expense.categoryId"is-editable="false"is-content-html="true"></wj-combo-box><labelclass="itemitem-input"><textareaplaceholder="Description"ng-model="expense.description"></textarea></label></div><divclass="button-bar"><buttontype="button"class="buttonbutton-darkicon-leftion-close"on-tap="cancel()">Cancel</button><buttontype="button"class="buttonbutton-balancedicon-leftion-checkmark"on-tap="addExpense()"ng-disabled="frmCreate.title.$invalid">Save</button></div></form></ion-content></ion-view>

这里使用ion-view 和 ion-content 指令进行内容展现。然后再添加Form,用ng-show指令验证输入内容---Wijmo的指令已经在输入门限做了限制,故不需要验证。同时Wijmo Calendar 和InputNumber应该是自解释,ComboBox中可能不是。

ComboBox关联数据模型中的开支分类,我们通过其itemsSource属性进行数据绑定。ComboBox的displayMemberPath 用于设置显示内容,selectedItem的selectedValue用于选择开支分类的id属性。

在createExpense 控制器中,可看到如下的代码片段:

//初始化Expenseobject$scope.expense=newExpense('',0,newDate(),'',null);//获得HTML类型的开支分类$scope.categories=CategorySvc.getCategoriesWithHtmlContent();//用localStorage存储开支数据$scope.addExpense=function(){//insertexpenseExpenseSvc.insertExpense($scope.expense);$scope.cancel();};//取消方法(如,可回到主页面)$scope.cancel=function(){$state.go('app.overview');};

上面的第一行代码用于初始化一个开支记录,用Expense的构造函数来实现,并赋值给$scope.expense对象。 开支分类,通过调用CategorySvc服务的接口,从localStorage获得数组。addExpense 方法用于提交新增的开支记录,同样用到了ExpenseSvc服务。最后一个函数$scope.canel使用了UI Router的 $state 服务,导航到主页面。

运行app,截图如下:

Details Grid

在前面几节中,我们分别学习了如何查看、创建、删除开支记录。在本节,我们将通过Wijmo5的FlexGrid和CollectionView批量对开支记录进行呈现,打开detailsGrid 模板文件,添加如下代码片段:

<ion-viewtitle="DetailsGrid"><!--setoverflow-scroll="true"andhandscrollingtonative--><ion-contentclass="has-header"overflow-scroll="true"><wj-flex-gridauto-generate-columns="false"items-source="data"selection-mode="Row"row-edit-ending="rowEditEnding(s,e)"><wj-flex-grid-columnwidth="2*"min-width="250"header="Title"binding="title"></wj-flex-grid-column><wj-flex-grid-columnwidth="*"min-width="100"header="Amount"binding="amount"format="c2"></wj-flex-grid-column><wj-flex-grid-columnwidth="*"min-width="100"header="Date"binding="date"></wj-flex-grid-column><wj-flex-grid-columnwidth="2*"min-width="250"header="Description"binding="description"></wj-flex-grid-column></wj-flex-grid></ion-content><ion-footer-barclass="barbutton-bar-footer"><divclass="button-bar"><buttontype="button"class="buttonbutton-darkicon-leftion-close"on-tap="cancel()">Cancel</button><buttontype="button"class="buttonbutton-balancedicon-leftion-checkmark"ng-disabled="!data.itemsEdited.length"on-tap="update()">Save</button></div></ion-footer-bar></ion-view>

在FlexGrid指令下面,我们添加了2个按钮,Cancel和Save,分别用于当点击的时候进行取消和存储操作,数据存储于localStorage。其中,Save按钮的默认不可用,通过ngDisabled的表达式进行控制。

FlexGrid 指令,用于在模板内生成Wijmo5的FlexGrid 控件。我们使用itemsSource 进行数据源绑定,同时通过autoGenerateColumns=”false”关闭自动生成数据列,以及SelectMode类型为整行Row。同时也设置了FlexGrid的rowEditEnding事件,用于验证数据输入。在FlexGrid内部,定义了Columns,分别指定了header、binding、width。

如下代码是detailsGrid 控制器片段:

//通过localStorage获得开支记录数据,并初始化CollectionView$scope.data=newwijmo.collections.CollectionView(ExpenseSvc.getExpenses());//CollectionView的变更可跟踪$scope.data.trackChanges=true;//批量更新开支记录$scope.update=function(){//makesureitemshavebeeneditedif($scope.data.itemsEdited.length){//bulkupdateexpensesExpenseSvc.updateExpenses($scope.data.itemsEdited);//returntooverviewpage$scope.cancel();}};//取消方法(如导航到主页面)$scope.cancel=function(){$state.go('app.overview');};//FlexGrid.rowEditEnding事件处理$scope.rowEditEnding=function(sender,args){varexpense=$scope.data.currentEditItem,//getexpensebeingeditedisValid=isExpenseValid(expense);//validateexpense//iftheexpenseisn'tvalid,canceltheeditoperationif(!isValid){$scope.data.cancelEdit();return;}};//验证函数:确保开支记录数据合法有效functionisExpenseValid(expense){returnexpense&&expense.title!==''&&expense.title.length<55&&wijmo.isNumber(expense.amount)&&wijmo.isDate(expense.date)&&expense.amount>=0;}

上面代码的第一行,通过从localStorage 加载数据,然后初始化CollectionView的对象,继而赋值给$scope.data对象,用于给前端HTML进行Data-Source绑定数据源。

接下来看cancel、update方法,cancel方法和上面的一样,使用了UI Router的$state服务进行回到首页。update方法,先进行数据判断,通过核查$scope.data.itemsEdited.length是否有效(是否有开支记录变更),然后再调用ExpenseSvc 进行数据修改,对localStorage数据进行存储处理。

最后,FlexGrid的rowEditEnding事件触发了rowEditEnding函数,即当row修改完成后尚未cancel、update前触发。在这里进行有效性判断,若无效则cancel并返回。这里,我们使用了Wijmo 5提供的工具函数:isNumber和isDate来进行判断。

运行Details Grid截图如下:

概述

修改app.routes.js 文件,从默认的history页面到overview页面:

$urlRouterProvider.otherwise('/app/history');to:$urlRouterProvider.otherwise('/app/overview');

这个细小的改变使得UI Router 会对没有明确重定向的,均会导向overview页面。

overview页面代码如下所示:

<ion-viewtitle="Overview"><ion-nav-buttonsside="right"><aclass="buttonbutton-iconiconion-plus"href="#/app/create"></a></ion-nav-buttons><ion-contentclass="has-headerpadding"><divng-show="hasExpenses"><hgroupclass="text-centerpadding-vertical"><h3class="title"><spanng-class="expensesCssClass">{{totalExpenses|currency}}</span>of{{budget|currency}}</h3><h5>{{budgetMsg}}</h5></hgroup><wj-flex-chartitems-source="categories"chart-type="Bar"binding-x="name"tooltip-content=""selection-mode="Point"footer="Tapthechart'sbarstoseehistorybycategory"selection-changed="selectionChanged(s)"item-formatter="itemFormatter"><wj-flex-chart-seriesbinding="total"></wj-flex-chart-series><wj-flex-chart-axiswj-property="axisX"format="c0"></wj-flex-chart-axis><wj-flex-chart-axiswj-property="axisY"reversed="true"major-grid="false"axis-line="true"></wj-flex-chart-axis></wj-flex-chart></div><divng-hide="hasExpenses"><h5class="paddingtext-center">Youhaven'taddedanyexpensesyet!Clickthe<iclass="iconion-plus"></i>buttontogetstarted!</h5></div></ion-content></ion-view>

上面的代码,首先使用hgroup元素呈现了开支记录的总和。下面接着使用了Wijmo 5 FlexChart 渲染了每个开支分类的开支金额,在FlexChart 指令内,我们指定了一些属性,如数据序列、x、y轴,同时当点击Bar的时候会触发FlexChart的plot elements 事件,对当前分类详情做列表呈现。

上面这些功能的实现,基于overview.js文件的逻辑:

//通过BudgetSvc服务,获得localStorage数据$scope.budget=BudgetSvc.getBudget();//判断是否有开支记录,返回bool$scope.hasExpenses=ExpenseSvc.hasExpenses();//获得开支的总金额$scope.totalExpenses=ExpenseSvc.getExpenseTotal();//获得各个分类的小计金额$scope.categories=ExpenseSvc.getCategoriesExpenseSummary();//初始化CSS样式$scope.expensesCssClass='energized';//设置开支金额显示字符串//NOTE:use$filterservicetoformatthetotalpriortoconcatenatingthestring$scope.budgetMsg=$scope.totalExpenses<=$scope.budget?$filter('currency')($scope.budget-$scope.totalExpenses).concat('untilyoureachyourmonthlylimit'):$filter('currency')($scope.totalExpenses-$scope.budget).concat('overyourmonthlylimit');//设置开支css样式$scope.expensesCssClass=0===$scope.totalExpenses?'dark':$scope.totalExpenses===$scope.budget?'energized':$scope.totalExpenses>$scope.budget?'assertive':'balanced';//***FlexChart'sselectionChangedeventhandler$scope.selectionChanged=function(sender){varcategory=null;if(sender.selection&&sender.selection.collectionView.currentItem){//getthecurrentlyselectedcategorycategory=sender.selection.collectionView.currentItem;//navigatetothecategoryhistorypagetodisplayexpensesforselectedcategory$state.go('app.category-history',{category:category.slug});}};//***setcolorofFlexChart'splotelements$scope.itemFormatter=function(engine,hitTestInfo,defaultFormat){if(hitTestInfo.chartElement===wijmo.chart.ChartElement.SeriesSymbol){//settheSVGfillandstrokebasedonthecategory'sbgColorpropertyengine.fill=hitTestInfo.item.bgColor;engine.stroke=hitTestInfo.item.bgColor;defaultFormat();}};

预览截图如下:

下载地址

在线演示地址

×××地址