质量经理 - IAC 2013 比赛参赛作品






4.93/5 (6投票s)
一个帮助现场 QA 检验员的应用程序
2013 年 IAC 比赛要点
- 该应用程序针对平板电脑平台
- 该应用程序可帮助现场代理和质量控制技术人员进行现场评估。
- 该应用程序正在提交至零售类别
- 该应用程序将使用 .Net 4.5/WPF 编写
介绍
本文介绍了我的 2013 年英特尔应用创新大赛参赛作品。我的应用程序专为基于现场的质量保证技术人员设计,其灵感来源于我在兄弟公司清洁业务中发现的需求。(更多信息请参阅背景。) 该应用程序的目标是简化现场工作的评估、相关报告的创建以及向关键决策者提交报告的过程,从而显著提高业务响应速度并改善整体客户满意度。
该应用程序面向在平板电脑形态的桌面上运行的 Windows 8 Pro 系统。它旨在客户现场使用。
该应用程序名为“Inspection Manager”(检查管理器),因为它确实就是这样做的。它帮助检查技术人员和业务经理跟踪现场检查,这些检查可能是业务线的一部分。虽然最初是为了满足公司清洁业务的需求而构思的,但该应用程序足够灵活,可以应用于任何质量检查/质量控制类型的数据收集和提交流程,横跨多个垂直行业。
该应用程序的主要功能包括:
- 检查员将能够前往客户地点进行计划性或一次性检查。这可能包括设施检查、产品检查或任何其他现场检查,检查员需要在其中一个或多个维度和类别中对某项进行评分。
- 检查员将能够(可选地)使用平板电脑或笔记本电脑(如果配备)中的 NFC 硬件读取被检查项目上的标签或设施内的关键位置的标签。NFC 标签可确保检查员在检查时在场。读取标签时,也可以使用或同时获取位置信息作为第二个级别的存在验证。(同样,如果设备配备得当)
- 该应用程序会响应 NFC 标签或用户交互,显示一个针对特定客户量身定制的空白检查报告。这可能来自本地存储或公司管理的云后端。
- 检查员将能够为分组的各种检查维度选择评分。如果需要检查其他区域,检查员可以在检查时通过添加或删除组来修改报告。组内的检查维度由检查模板驱动,在检查时不能修改,以确保组内的检查维度保持一致。(例如,“办公室”区域可能在检查模板中定义为一个组,包含要检查的多个维度,如除尘、垃圾、窗户等。在检查时,办公室区域的维度由模板驱动,但被锁定,不可添加或删除。但是,“办公室”组可以多次添加到报告中,以覆盖给定建筑物中可能需要检查的所有单独的办公室区域。)
- 如果需要,检查员将能够使用内置摄像头(如果存在)拍照,并将其注释并附加到特定的检查维度,用于文档记录或未来的跟进。
- 如果检查员被中断,报告将以 WIP(进行中)状态保存,并根据需要持久化到云存储进行备份。
- 检查完成后,检查员将“提交”检查。此时,检查报告可以通过电子邮件发送给内部或外部联系人,如果合适,也可以提交到云后端。
- 检查员可以选择通过便携式蓝牙打印机或 WiFi 打印机在现场打印报告。
- 该应用程序还将向用户推送通知,告知他们先前计划的检查到期。
- 应用程序的开始磁贴将显示必须完成的待处理检查以及即将进行的检查。
- 与搜索合同的集成将允许用户通过搜索魅力发送搜索查询到应用程序。
- 与设置合同的集成将允许用户通过设置魅力修改应用程序设置。
- 与 SkyDrive 的集成将提供共享检查模板和将结果报告保存为 PDF 的能力。
- 与基于 Azure 的后端的集成将允许大型公司集中维护和监控检查模板、客户、日程安排和结果。
该应用程序将使用 WPF 和 C# 编写,并进行项目修改以确保它仅在 Windows 8 上运行。这是访问 WPF 桌面应用程序中的 WinRT 传感器堆栈的要求。传感器堆栈用于位置数据。
该程序将遵循现代 UI 设计原则。因为它专为现场使用而设计,所以它将面向在平板电脑上使用触摸作为主要输入方式进行操作,但也将支持基于键盘/鼠标的交互。虽然它会尝试看起来像一个 WinRT 应用程序(全屏打开,运行在单个窗口中),但它将是一个桌面应用程序,可以被窗口化,并且有一个开始栏图标。
背景
这个想法源于我兄弟的公司清洁业务。他们一直在与“现场情报”作斗争……即了解现场正在发生什么。他们的整个业务都基于其员工在客户的设施中(正确地)完成工作。这是分布式工作环境的一个极端例子。
问题在于如何掌握正在发生的事情。工作是单调乏味的,清洁工的注意力很容易转移,导致工作质量下降。通常,在办公室收到问题通知之前就已经太晚了,因为他们通常在客户打电话投诉……而且通常是大声抱怨……关于他们建筑中的问题时,才会意识到问题。
在这种情况下,他会雇佣现场检查员定期或突然进行检查访问客户站点,以评估他的清洁工的工作。这些信息非常宝贵,因为它提供了问题的早期预警,在出现问题时提供了文档,并且能够向他的清洁工提供真实、可操作的反馈,让他们做得更好。但是,检查过程很麻烦,因为它主要是手动进行的,而且现场检查与办公室了解现场发现的问题之间的时间延迟可能长达数天。响应时间通常是客户无法接受的,从而降低了检查计划的整体效益。
解决方案,至少是这里提出的解决方案,是创建检查报告的电子版本,并能够立即将其提交回总部采取行动。平板电脑形态是此类操作的理想选择。检查员可以携带平板电脑进入客户地点,并根据为该设施提供的检查模板进行检查。 这样报告就可以针对设施进行定制……这是纸质报告永远无法实现的。轻松输入评论,更重要的是,拍摄问题区域的照片,这为检查员提供了解决问题所需的“弹药”,并确保客户满意。
执行检查是一回事,处理它又是另一回事。主要功能之一是能够将检查结果打印到便携式蓝牙打印机或 WiFi 打印机。这允许检查员立即向客户提供一份报告副本。由于此应用程序将在桌面模式下运行,因此理论上支持 Windows 中安装的任何打印机。无线或蓝牙打印机只是此应用程序最方便的选择,因此是专门针对的目标。
此外,由于许多平板电脑已经配备了移动数据,或者可以通过热点或 WiFi 连接进行“激活”移动数据,因此该应用程序将监视互联网连接,并在实际检查完成后不久立即将检查发送到中央办公室进行处理。这缩短了问题识别到办公室意识到问题之间的时间,从几天缩短到几小时甚至几分钟,让公司有机会开始解决问题。这缩短了问题识别与办公室意识到问题之间的时间,从几天缩短到几小时甚至几分钟。
数据连接将是一个挑战。现场连接的间歇性意味着应用程序必须了解数据连接状态并相应地优雅地降级功能。这也意味着大部分信息需要本地缓存,以便用户能够及时获得信息,同时确保在数据连接可用时在后台进行更新。 异步网络支持,在用户干预的情况下在后台上传和下载数据,将是使应用程序响应的关键。
更广泛的吸引力
此应用程序最令人兴奋的方面之一是,尽管它源于响应单一垂直市场,但它适用于许多可能的细分市场。任何需要进行现场检查,涉及对一个或多个检查维度进行评分,并且可能需要注释这些维度或拍照的场景,都有可能应用此程序。建筑、制造和服务行业都可以通过这种运行在平板电脑形态上的程序,在缩短现场问题识别和纠正时间方面获得帮助,从而提高客户满意度。 甚至零售或餐厅环境中的设施或质量控制检查也都是此应用程序的理想应用场景。
解决问题的方法
该应用程序无疑是雄心勃勃的。因此,将需要一个实施时间表,以免我偏离轨道或不知所措。
初始阶段将是创建 UI 框架,并将应用程序开发到可以发送检查报告的程度。检查应由最终用户配置,这意味着必须有一个定义这些检查的机制,以及一个足够灵活的应用程序来按定义读取和显示它们。检查最终将是一个分层的数据结构。顶部是报告,包含客户信息、日期/时间、位置信息以及其他报告特定的信息,例如报告是用户启动的还是从位置 NFC 标签启动的。
报告将包含一个或多个检查组。组是可能需要检查的区域……逻辑上是一组适用于单个事物或区域的维度。一个报告可以只包含一个区域,但更可能包含几个。对于清洁检查,这可能包括浴室、办公室区域、大堂等。
检查组将包含一个或多个检查维度。这些是检查报告中的实际项目。以大堂区域为例。大堂中的维度可能包括垃圾桶、门、窗户、灯具等。每个维度都有一个评分。这可能是差、可接受、高于平均水平。办公室经理或其他行政人员将决定这些评分是什么,并将统一应用于检查维度以保持一致性。
该层次结构将由一个模板文件驱动,该文件将定义每个区域的所有可能值以及整个检查的格式。在第一阶段,应用程序将仅打开一个模板,并允许检查员填写并发送结果。
应用程序的第二阶段将增加对在设备上保存和检索检查的支持。这将要求向检查报告添加工作状态标志,以便应用程序知道检查是 WIP(进行中)还是已完成。我还需要一个本地数据库来以关系方式存储模板数据和检查数据。第二阶段还将增加对通过蓝牙或 WiFi 打印机打印报告以及将报告导出为 PDF 的支持。
第三阶段将增加 SkyDrive 和/或 Google Drive 支持,允许用户将存储同步到云存储服务,以便于访问和灾难恢复。运行在不同设备上的多个程序将能够访问云存储位置中共享的模板。
最后阶段将是添加一个基于 Azure 的后端,这将允许愿意付费订阅的大型客户获得自动同步的信息,并集中管理他们所有的检查平板电脑。还将提供集中的调度和报告。此阶段是在比赛之后,因为开发后端将有大量工作,并且不属于本次比赛的范围。
数据设计
关键在于设计数据模式来驱动整个系统。它必须足够灵活,允许最终用户进行自定义以满足他们的需求,但仍需符合解决方案设想的层次结构。
以下是数据结构设计,让您了解报告、组和维度实体如何相互关联:
数据模型
由于此应用程序的数据结构需要在多个平台(WPF 和 Azure)上使用,而且我非常喜欢 MVVM,因此创建一个数据模型来以平台无关的方式公开数据是有意义的。这就是可移植类库 (Portable Class Libraries) 的用武之地。虽然 PCL 在 .Net 领域似乎仍然是一个谜,但很明显,这种情况正是它们发挥作用的地方。为此,将代表报告、组和维度对象的类将编写在 PCL 项目中,并链接到主应用程序。用于获取和保存数据的函数将定义在另一个 PCL 项目中,该项目将指定存储方法的接口。这些接口将在每个特定平台上实现,以隐藏该特定平台上数据存储的细节。这样,它们就可以在 WPF 和 Azure 平台重用,以一种一致的方式公开模型。
用户界面
UI 是应用程序的关键。数据结构和后端功能可能很棒,但如果应用程序不直观且易于非计算机专业人士使用,更不用说平板电脑用户了,那么该应用程序就会失败。为此,我采用了现代 UI 设计策略,虽然应用程序是桌面应用程序,但我希望它具有 Windows 应用商店应用程序的外观和感觉。 为了加速这一过程,我计划使用 MahApps.Metro 框架作为起点,使应用程序看起来“现代”。(我使用的并且强烈推荐的 XAML Spy,就利用了该框架的界面。)
主页
应用程序的主页面充当系统收集和管理的信息的门户。从主页面,用户可以创建一个新检查、一个新模板,或者编辑现有模板。在查看新检查视图时,用户将看到公司客户作为按名称组织的磁贴,显示一些额外的信息,例如上次检查该地点的时间。 触摸磁贴将打开该客户的新检查。按住磁贴将打开一个菜单,用于其他选项,例如将客户地址传递给映射应用程序以导航到该客户。
用户还可以通过从右向左滑动切换到 WIP 视图,在该视图中将显示已开始但尚未完成的检查。WIP 检查磁贴将包含一个“发光”效果,该效果会根据检查的年龄从绿色变为黄色再变为红色。列表将按从旧到新的顺序排列,因此检查员可以快速看到最重要的 WIP 检查。按住 WIP 磁贴可实现多选和删除 WIP 检查。
页面支持的最后一个主要部分是“审查”视图。审查部分将允许用户返回并以只读方式查看之前提交的检查。一旦检查被提交,应用程序将“锁定”该检查,使其无法修改。这可以保留检查的原样,并防止某人在事后更改检查结果。其理念是,打印给客户的检查报告应与系统中存储的报告相同。如果存储的报告与打印给客户的报告不匹配,那么该应用程序的信任度将不高。通过阻止对已提交检查的更改,我们保留了该检查的“取证证据”。 在此视图中选择检查磁贴将简单地将用户带回报告页面,但编辑数据的能力将被移除。用户将能够从此页面重新打印报告。
新检查:
用户在创建新的检查报告时将进入新检查页面。报告顶部有一个用于添加新组的栏。选择“添加新组”按钮将显示可用检查组的列表供用户选择。选择其中一个将该组添加到报告中。
组可以通过展开器访问,这些展开器将显示一组要检查的维度网格。每个维度都有名称、评分、可选注释,以及拍照并将其关联到该维度的能力。通过触摸导航网格和触摸选择评分将使检查员轻松填写报告。所有维度都应用默认评分,以节省检查员手动选择每个维度的时间。
底部的操作栏是用户可以提交报告、打印报告以及将检查报告保存到云中的地方。打印按钮将在报告提交后才可用。同样,这是为了确保报告的提交版本与打印版本保持一致。
拍照
报告中的摄像头列包含按钮,这些按钮将启动平板电脑上的摄像头,并允许检查员拍摄一张照片,该照片将与该维度关联。数据网格上图标的颜色将指示该维度是否存在照片。 最初,图标是黑色的,表示没有关联图像。按下摄像头按钮将用户带到图像捕获屏幕。
该程序将使用 XAML 工具包 (https://winrtxamltoolkit.codeplex.com/) 中的 CameraControl 库来管理摄像头交互。CameraControl 提供了一个很好的起点,因为它允许以异步方式进行图像捕获。该库基于 WinRT,但由于 Inspection Manager 将使用 WinRT 堆栈构建,因此这对运行在 Windows 8 上的 WPF 应用程序来说不是问题。
前提是用户已允许 Inspection Manager 访问摄像头(这一点很重要!),摄像头窗口将打开并显示摄像头的实时视图。按下覆盖在图像上的摄像头按钮即可拍照。摄像头控件的演示使用了一个有趣的复古电影倒计时控件来显示何时拍照。我将使用这种模式,因为它确实能帮助用户。因此,按下按钮将显示倒计时,然后拍照。如果用户决定不想要这张照片,再次按下拍摄按钮将中止正在进行的捕获。
左侧的双箭头将出现在同时拥有前后摄像头的硬件上。按下按钮将在摄像头之间切换。虽然我怀疑是否有人会在配备后置摄像头的设备上使用前置摄像头,但我仍然会提供它。 也许检查员想在漂亮的大堂接待员旁自拍……
放大镜按钮将允许用户搜索图像库,查找可能已拍摄的图像。当检查“离线”完成时,或者在客户拍摄了照片并将其通过“碰触传输”转移到检查员平板电脑的情况下,这会很有用。搜索并从图库中选择图像将允许将客户的照片导入检查中。
编辑图标仅在拍摄或加载图像后才可用。如果实时拍摄了图像或从查找图像对话框中选择了图像,页面将自动切换到编辑屏幕。按钮将变为活动状态,但图像不会自动变为可编辑状态。
拍摄图像后,捕获的图像将移到左侧,并出现一个分割条。在其右侧,将显示来自摄像头的实时图像的缩小版本。同样,这遵循了 XAML 工具包中的摄像头控件示例。我非常喜欢这个概念,所以我在这里采用了它。
用户可以将分割条滑动到页面的 0%、33%、66% 或 100% 位置,类似于 WinRT 在一个屏幕上并排显示两个应用程序的方式。这允许用户在完整捕获的图像、大尺寸捕获图像和小尺寸实时图像(如上所示)、小尺寸捕获图像和大尺寸实时图像之间切换,或者返回到全尺寸实时图像。 在实时图像上,如果用户再次按下拍摄按钮,捕获将重新开始(同样,按下第二次按钮可取消),替换先前捕获的图像。
当图像被捕获后,左侧的编辑按钮将变为活动状态。如果用户什么都不做并按下返回按钮,未编辑的图像将与关联的检查维度一起存储。如果用户按下编辑按钮,图像将进入编辑模式。
当图像被捕获后,它不会被放入常规图像控件中,而是放入 GdPicture.Net 图像控件中。(http://www.gdpicture.com/)当用户按下编辑按钮时,GdPicture 控件将进入编辑模式,并出现一个工具栏,允许用户使用标记(如线条和文本)注释图像。GdPicture 控件在这里节省了大量时间,因为它已经支持图像编辑和操作功能,以及触摸操作,如双指旋转和捏缩放。这使我能够用最少的精力将一个相当强大的图像编辑器嵌入到 Inspection Manager 应用程序中。用户会期望这些功能,而程序将提供它们,而无需我来实现它们。
当用户完成图像的捕获和操作后,按下返回箭头将他们带回到检查报告,在那里他们将看到主题维度上的摄像头图标变成了红色,表示该维度已捕获图像。按下红色的摄像头将用户带回到图像编辑屏幕(这次实时摄像头图像将被隐藏,并且编辑功能将已启用),在那里他们可以编辑图像,或者滑动分割条并拍摄新图像以替换之前的图像。
在报告的打印版本中。摄像头列将解析为一个脚注编号,图像将打印在报告末尾,并由该编号引用。这将允许以合理的尺寸打印图像,但需要有人在报告数据和引用的照片之间来回查阅。
有趣之处
因此,有许多我发现有趣的方面,它们超越了数据收集应用程序的表面。其核心是,Inspection Manager 仅仅是一个特定任务的数据收集工具。但要发挥作用,它需要提供比这更多的功能。数据收集很好,但你如何处理这些数据才是区分竞争公司和“想成为者”的关键。为此,在开发此应用程序时,有几点是优先考虑的。
触摸,触摸,触摸!
平板电脑形态和触摸屏功能正在改变我们与计算机的交互方式。Windows 8 证明了我们正在经历这种根本性的变化。幼儿尝试滑动杂志页面的视频进一步印证了这样一个观点:随着硬件发布周期的不断更新,触摸将越来越多地成为我们 UI 期望的基础。开发人员需要牢记这一点,并设计他们的用户体验,不仅要包含触摸,而且要从根本上以触摸为用户体验的中心。它应该是以触摸为主,辅以键盘/鼠标支持……而不是反过来。
幸运的是,.NET 4.5,特别是 WinRT 部分,提供了大量的触摸支持。为触摸设计 UI 需要不同的思维过程,但现代 UI 设计原则有利于触摸支持,所以我认为这不会是过程的难点。但是,控件库中的触摸支持仍然不足。要在 WPF 应用程序中实现引人注目的触摸体验,从技术角度来看将是具有挑战性的,因为它可能需要一些自定义触摸处理代码。希望一些聪明人已经开发了一些库来提供帮助,但此时,我认为触摸支持是该项目的风险领域。
保持简单。
- KISS(保持简单愚蠢)原则被广泛引用是有原因的。如果你看看现代 UI,KISS 原则得到了充分体现。这不是巧合……对于这个应用程序,我需要确保我不断挑战我的假设,并通过这个规则来过滤它们。有没有更好的方法让用户更容易使用?事实是,此应用程序的目标用户并非真正的技术人员。他们需要直观的东西,符合他们理解的范例。他们中的许多人可能拥有智能手机,但可能对计算机总体上不熟悉。此应用程序需要足够简单,以便某人能够上手,无需指导即可使用它来创建检查报告。听起来很简单,但这很具挑战性,因为我必须将功能缩小到一个用户熟悉的范例(智能手机)。
代码重用
我们都是成年人……我认为我不需要论证为什么要重用代码。但我会提到,可移植类库首次实现了真正的跨平台代码库,而没有大量 `#if` 编译器指令来强制 MSBuild 发出平台特定的二进制文件。有了 PCL,我可以编写一次库,然后在多个平台上使用。我以前这样做过,我很高兴在这个项目中再次这样做,并将其用于更强大的东西。有时编写最低公分母是很有挑战性的,但好处是,一个库将满足此应用程序多个前端和后端版本的模型。
提供业务认为应该有的功能
- 没有什么比在一个应用程序中寻找一个本应存在但却不存在的功能更让我抓狂的了,因为开发人员没有考虑或理解他们的用例。将使用此应用程序的业务和用户将期望某些功能。他们会期望它能自动检测网络连接。他们会期望能够跨多个设备共享和管理模板。他们会期望有一些分析,也许还有一个用于获取系统数据的 API。这些是我在构建此应用程序时需要考虑、识别和优先考虑的事情。
不要过于激进
- 这是对提供业务所需功能的需求的一种平衡。使用该应用程序的每个人都会希望看到一些其他功能被添加进来。甚至我也会有同样的想法。但允许功能蔓延会让应用程序变成一个在比赛时间范围内无法交付的东西,更糟糕的是,在一个可能对市场有用的时间范围内无法交付。他们说你应该趁热打铁,而这款应用程序填补的市场空白是目前存在的。限制应用程序的功能,使其成为一个简单且执行良好的功能集,将有助于它抢占先机,建立声誉和吸引力,然后再让竞争对手占据一席之地。在当今的商业环境中,这一点很重要,并且可以决定一个应用程序,以及一个公司的最终成败。
项目更新
(或者我的头发为什么很快变白)
由于我们已经进入比赛中期,而且我在工作上有一段空闲时间,我有一些时间来更新这篇文章,介绍我的进展。总而言之,进展顺利,我对应用程序的成型方式非常满意,但时间过得非常快,我肯定感到压力要完成它。我一直发现的一件事是,无论你在前期投入多少精力进行思考和设计项目,一些事情在进入实施阶段时会发生变化,而要产出高质量的产品总是比你最初认为的要花费更多的时间。
我在这项目上做出的一个重大决定是使用可移植类库 (Portable Class Libraries) 来创建数据结构和对象。PCL 允许你创建跨运行时堆栈兼容的库。目前,你可以编写一次库,然后使其在 WPF、Silverlight、ASP 等平台上运行,而无需诉诸共享代码文件和编译器指令。对于可能存在于多个平台上的项目,PCL 现在是我首选的解决方案。 我的打算是将此应用程序移植到其他运行时,特别是 WinRT,所以对我来说,使用 PCL 来建模我的数据是理所当然的。
数据对象
对于这个项目,我创建了两个 PCL 项目。其中一个,Inspection Manager Interfaces,仅包含项目所需各种数据对象的接口定义。这创建了一个单一的库,该库公开了这些接口,并且可以在我想要的任何平台上使用。接口库还公开了一个自定义异常,在期望的对象在存储中找不到时,实现这些接口需要抛出该异常。
这是检查报告接口的样子
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace AerialBreak.InspectionManager.DAO.Interfaces
{
/// <summary>
/// Provides the interface for the Inspection Report
/// </summary>
public interface IInspectionReport : INotifyPropertyChanged
{
/// <summary>
/// The ID number for this record
/// </summary>
int ID { get; set; }
/// <summary>
/// The name of the report
/// </summary>
string Name { get; set; }
/// <summary>
/// The ID of the associated customer record
/// </summary>
int CustomerID { get; set; }
/// <summary>
/// The ID of the associated customer location
/// </summary>
int LocationID { get; set; }
/// <summary>
/// The ID of the associated scoring system
/// </summary>
int ScoringID { get; set; }
/// <summary>
/// The name or username of the inspector creating the report
/// </summary>
string UserName { get; set; }
/// <summary>
/// The date & time the record was created
/// </summary>
DateTime? StartDateTime { get; set; }
/// <summary>
/// The date and time the record was last saved
/// </summary>
DateTime? LastSavedDateTime { get; set; }
/// <summary>
/// The date and time the report was submitted
/// </summary>
DateTime? SubmittedDateTime { get; set; }
/// <summary>
/// The tracking value for the current record state
/// </summary>
int CurrentState { get; set; }
/// <summary>
/// The data stored in the NFC tag read in association with the report
/// </summary>
string NfcTagValue { get; set; }
}
}
我还有第二个接口,它是第一个接口的复数形式,声明了处理这些对象所需的方法。
/// <summary>
/// Provides the public interface for the management of the inspection report objects
/// </summary>
public interface IInspectionReports
{
/// <summary>
/// Gets all of the reports in the system
/// </summary>
/// <returns>Collection of report objects</returns>
ObservableCollection<IInspectionReport> GetReports();
/// <summary>
/// Gets all of the reports in the system for the specified customer and optional location
/// </summary>
/// <param name="customerID">The customer ID to search for</param>
/// <param name="locationID">(OPTIONAL) The location ID to filter the results with</param>
/// <returns>Collection of the report objects that match the required criteria</returns>
/// <remarks>If the customer ID does not exist in the system, this method will throw an InvalidIDException.
/// If the location ID is specified and does not exist in the system or is not associated with the corresponding customer, the
/// method will thrown and InvalidIDException.</remarks>
ObservableCollection<IInspectionReport> GetReports(int customerID, int locationID = 0);
/// <summary>
/// Gets all of the records in the system with the specified user name
/// </summary>
/// <param name="userName">The user name to search for</param>
/// <returns>Collection of inspection records with the specified user name attached.</returns>
/// <remarks>If there are no records with the specified user name, the method will return an empty collection.</remarks>
ObservableCollection<IInspectionReport> GetReports(string userName);
/// <summary>
/// Gets all the reports that were opened during the specified date range.
/// </summary>
/// <param name="rangeBegin">(OPTIONAL) The start date and time to return records within. (Inclusive)</param>
/// <param name="rangeEnd">(OPTIONAL) The ending date and time to return records within. (Exclusive)</param>
/// <returns>Collection of inspection records which fall between the start and end dates.</returns>
/// <remarks>The default begin and end times are the minimum and maximum date values causing the method to return all records in the system.
/// If no records match, an empty collection will be returned.</remarks>
ObservableCollection<IInspectionReport> GetReportsByStartDate(DateTime? rangeBegin, DateTime? rangeEnd);
/// <summary>
/// Gets all of the reports that were saved during the specified date range.
/// </summary>
/// <param name="rangeBegin">(OPTIONAL) The start date and time to return records within. (Inclusive)</param>
/// <param name="rangeEnd">(OPTIONAL) The ending date and time to return records within. (Exclusive)</param>
/// <returns>Collection of inspection records which fall between the start and end dates.</returns>
/// <remarks>The default begin and end times are the minimum and maximum date values causing the method to return all records in the system.
/// If no records match, an empty collection will be returned.</remarks>
ObservableCollection<IInspectionReport> GetReportsByLastSavedDate(DateTime? rangeBegin, DateTime? rangeEnd);
/// <summary>
/// Gets all of the records that were submitted during the specified date range.
/// </summary>
/// <param name="rangeBegin">(OPTIONAL) The start date and time to return records within. (Inclusive)</param>
/// <param name="rangeEnd">(OPTIONAL) The ending date and time to return records within. (Exclusive)</param>
/// <returns>Collection of inspection records which fall between the start and end dates.</returns>
/// <remarks>The default begin and end times are the minimum and maximum date values causing the method to return all records in the system.
/// If no records match, an empty collection will be returned.</remarks>
ObservableCollection<IInspectionReport> GetReportsBySubmittedDate(DateTime? rangeBegin, DateTime? rangeEnd);
/// <summary>
/// Gets all of the records within the current state range
/// </summary>
/// <param name="lowerState">The lower value of the current state range (INCLUSIVE).</param>
/// <param name="upperState">(OPTIONAL) The upper value of the state range. If this value is lower than the lower one, the maximum value will be used. (INCLUSIVE)</param>
/// <returns>Collection of inspection records where the current state falls within the specified range. If no records match, an empty collection will be returned.</returns>
ObservableCollection<IInspectionReport> GetReportsByState(int lowerState, int upperState = 0);
/// <summary>
/// Gets all of the records with the specified NFC Data
/// </summary>
/// <param name="nfcData">The NFC data to search for</param>
/// <returns>Collection of inspection records with matching NFC data. If no records match, an empty collection will be returned.</returns>
ObservableCollection<IInspectionReport> GetReportByNfcString(string nfcData);
/// <summary>
/// Returns the record with the corresponding ID
/// </summary>
/// <param name="reportID">The ID of the inspection record to return.</param>
/// <returns>The specified inspection record. If the record ID does not exist, the method will thrown an InvalidIDException.</returns>
IInspectionReport GetReport(int reportID);
/// <summary>
/// Adds or updates the system with the specified record.
/// </summary>
/// <param name="report">The inspection record to save or update.</param>
/// <returns>The ID of the record.</returns>
/// <remarks>If the ID parameter of the report is not set, the method will assume the record should be added to the system.
/// If the ID parameter is set, the method will attempt to update the specified record. If the record does not exist, an InvalidIDException will be thrown.</remarks>
int SetReport(IInspectionReport report);
/// <summary>
/// Sets the LastSavedDate for the specified record to the current date & time.
/// </summary>
/// <param name="reportID">The ID of the record to update</param>
/// <returns>True if successful</returns>
/// <remarks>If the ID is not present in the system, the method will return an InvalidIDExpception.</remarks>
bool SetReportLastSavedDate(int reportID);
/// <summary>
/// Sets the Submitted date for the report to the current date & time.
/// </summary>
/// <param name="reportID">The ID of the record to update</param>
/// <returns>True if successful</returns>
/// <remarks>If the ID is not present in the system, the method will return an InvalidIDExpception.</remarks>
bool SetReportSubmittedDate(int reportID);
/// <summary>
/// Sets the current state for the specified report to the provided state value
/// </summary>
/// <param name="reportID">The ID of the record to update</param>
/// <param name="newState">The new state value.</param>
/// <returns>True if successful</returns>
/// <remarks>If the ID is not present in the system, the method will return an InvalidIDExpception.</remarks>
bool SetReportCurrentState(int reportID, int newState);
/// <summary>
/// Sets the NFC data for the specified record
/// </summary>
/// <param name="reportID">The ID of the record to update</param>
/// <param name="nfcData">The NFC data string to save</param>
/// <returns>True if successful</returns>
/// <remarks>If the ID is not present in the system, the method will return an InvalidIDExpception.</remarks>
bool SetReportNfcData(int reportID, string nfcData);
}
这种设置给了我两个接口,一个用于数据对象,一个用于处理数据对象所需的方法。我之所以这样做,是因为我发现这样处理对象更容易、更灵活。具体来说,我可以用这种设置做到的是,在另一个库或项目内部实现实际的数据对象,然后使用适合平台的任何方式单独实现处理方法。例如,在这个项目上,我选择使用 SQL Lite 作为后端数据存储。在实际的 WPF 项目中,我使用 PCL 中定义的接口来实现数据处理方法,但设置为使用 SQL Lite 作为存储机制。 此项目的 WinRT 版本可能会使用 Azure Mobile Services 来存储数据。从应用程序的角度来看,这无关紧要,因为接口(和二进制文件!)对两个项目来说都是相同的。
我有一个数据上的失误,我希望我没有这样做,那就是在一个第二个 PCL 中创建了数据对象的实现。我曾认为这是一种在项目之间重用它们的便捷方式,虽然在纸面上看起来是合理的,但它给对象增加了一个我实际上不需要的继承层。当我开始实际使用这些对象时,我发现我需要为 SQL Lite 添加装饰,以便它能够自动创建表。如果我在 PCL 中的属性上添加了装饰,我就在 PCL 库中创建了一个对 SQL Lite 的依赖,而这可能无法在 PCL 可以支持的其他平台上实现。所以我的唯一选择是继承这些对象并创建一个覆盖属性,其中包含装饰。这是数据对象 PCL 实现的样子
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using AerialBreak.InspectionManager.DAO.Interfaces;
using AerialBreak.InspectionManager.Utility;
namespace AerialBreak.InspectionManager.DAO.Objects
{
public class KeyedSetting : NotifyPropertyChangedBase, IKeyedSetting, INotifyDataErrorInfo
{
private int _id;
public int ID
{
get
{
return _id;
}
set
{
if (_id != value)
{
_id = value;
OnPropertyChanged();
}
}
}
private int _associatedRecordId;
public int AssociatedRecordID
{
get
{
return _associatedRecordId;
}
set
{
if (_associatedRecordId != value)
{
_associatedRecordId = value;
OnPropertyChanged();
}
}
}
private string _parentKey;
public string ParentKey
{
get
{
return _parentKey;
}
set
{
if (_parentKey != value)
{
_parentKey = value;
OnPropertyChanged();
}
}
}
private string _itemKey;
public string ItemKey
{
get
{
IsItemKeyValid(_itemKey);
return _itemKey;
}
set
{
if (IsItemKeyValid(value) && _itemKey != value)
{
_itemKey = value;
OnPropertyChanged();
}
}
}
private string _value;
public string Value
{
get
{
IsValueValid(_value);
return _value;
}
set
{
if (IsValueValid(value) && _value != value)
{
_value = value;
OnPropertyChanged();
}
}
}
private string _modifier;
public string Modifier
{
get
{
return _modifier;
}
set
{
if (_modifier != value)
{
_modifier = value;
OnPropertyChanged();
}
}
}
#region Data Validation
private const string ITEMKEY_ERROR = "A name for the key must be provided.";
private const string VALUE_ERROR = "A value for the key must be provided.";
#region Property Validators
// Validates the Name property, updating the errors collection as needed.
public bool IsItemKeyValid(string value)
{
bool isValid = true;
if (string.IsNullOrEmpty(value))
{
AddError("ItemKey", ITEMKEY_ERROR, false);
isValid = false;
}
else RemoveError("ItemKey", ITEMKEY_ERROR);
return isValid;
}
public bool IsValueValid(string value)
{
bool isValid = true;
if (string.IsNullOrEmpty(value))
{
AddError("Value", VALUE_ERROR, false);
isValid = false;
}
else RemoveError("Value", VALUE_ERROR);
return isValid;
}
#endregion
#region INotifyDataErrorInfo boilerplate
private Dictionary<String, List<String>> errors = new Dictionary<string, List<string>>();
// Adds the specified error to the errors collection if it is not
// already present, inserting it in the first position if isWarning is
// false. Raises the ErrorsChanged event if the collection changes.
public void AddError(string propertyName, string error, bool isWarning)
{
if (!errors.ContainsKey(propertyName))
errors[propertyName] = new List<string>();
if (!errors[propertyName].Contains(error))
{
if (isWarning) errors[propertyName].Add(error);
else errors[propertyName].Insert(0, error);
RaiseErrorsChanged(propertyName);
}
}
// Removes the specified error from the errors collection if it is
// present. Raises the ErrorsChanged event if the collection changes.
public void RemoveError(string propertyName, string error)
{
if (errors.ContainsKey(propertyName) &&
errors[propertyName].Contains(error))
{
errors[propertyName].Remove(error);
if (errors[propertyName].Count == 0) errors.Remove(propertyName);
RaiseErrorsChanged(propertyName);
}
}
public void RaiseErrorsChanged(string propertyName)
{
if (ErrorsChanged != null)
ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
}
#endregion
#region INotifyDataErrorInfo Members
public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
public IEnumerable GetErrors(string propertyName)
{
if (String.IsNullOrEmpty(propertyName) ||
!errors.ContainsKey(propertyName)) return null;
return errors[propertyName];
}
public bool HasErrors
{
get
{
return errors.Count > 0;
}
}
#endregion
#endregion
}
}
由于我的失误,我在 WPF 项目中有一个类实现了这个数据对象的“派生”版本。
using AerialBreak.InspectionManager.DAO.Interfaces;
using AerialBreak.InspectionManager.DAO.Objects;
namespace InspectionManager_WPF.DerivedDataObjects
{
public class DerivedKeyedSetting : KeyedSetting, IKeyedSetting
{
[SQLite.AutoIncrement, SQLite.PrimaryKey]
public int ID
{
get
{
return base.ID;
}
set
{
base.ID = value;
}
}
}
}
你可以看到,它所做的就是重新声明 ID 属性,并带有两个装饰。我本应该在主项目中实现数据对象,而不是在第二个 PCL 中,这样我就可以通过直接装饰实现来跳过继承。如果我有时间,我会回去撤销这一点。
INotifyDataErrorInfo
你可能在实现中看到的另一个是 INotifyDataErrorInfo 接口。我热爱这种机制,用于这样的数据模型类。它为数据验证和通信创建了一个标准接口。酷炫之处在于,文本框等 UI 控件已知此接口,并且会自动提示用户,如果提供的值不符合要求。在这里,你可以看到 UI 如何提示用户需要输入合法的企业名称(这在客户设置屏幕上)。最神奇的部分是我在 ViewModel 或 View 中没有编写任何代码来实现这一点!文本框检测到它绑定的类上存在 INotifyDataErrorInfo 接口,并为我处理了通知。
用户界面
我选择采用Metro现代 UI 风格来做这个项目,我非常高兴我这样做了。主界面使用一个“标签”视图,但没有标签。在这里,我们可以看到“管理”部分(标签)显示了子部分(客户和模板)以及两个磁贴。(空白磁贴是“添加”磁贴,应该有一个加号。)
我使用了 MahApps metro 工具包来获得 UI 风格,我对此非常满意。它需要一点学习曲线,因为文档有点稀疏,但总的来说我非常满意。它附带的全景控件 (pano control) 很不错,效果也很好。它确实让我用很少的努力就获得了如此好的 UI。
我非常喜欢的另一个功能是该工具包中的水印附加控件。它允许你为文本框添加水印,看起来很漂亮,并为用户提供有关所需信息类型的提示。客户设置页面是该控件的一个很好的例子。用户需要输入他们正在创建的客户记录的信息,而水印使应用程序看起来专业,同时也提示用户应该提供什么信息。
像所有好的数据驱动应用程序一样,我有一个网格。实际上有几个,但谁在数呢? :-) 对于我的程序,用户需要设置一个评分系统,因为这实际上是整个程序的核心。检查的元素使用定义的评分系统中的值进行评分。网格是自然的选择。我的程序中的实现如下所示
我添加的一个很酷的功能是网格底部的分页控件。因为这个程序将在垂直空间有限的平板电脑上使用,所以我不想让用户无限滚动来查找他们想要的东西。因此,分页控件将网格条目限制为每页 10 个,如果用户的数据集有 10 行以上,则自动分页。虽然在定义评分系统时这不是特别大的问题,但在创建实际的检查报告时却成为一个问题。办公室区域、浴室或任何其他区域可能很容易有超过 10 个必须检查和评分的项目。为了保持 UI 的紧凑和易于管理,分页控件非常有用!
第一部分总结
到目前为止,项目进展基本按计划进行。我知道我需要先创建数据对象,然后是 UI(和应用程序框架),然后完成所有“开销”工作。这在目前基本上完成了。应用程序可以运行,用户可以创建评分系统、报告模板、客户和地点。基本上,开始报告和执行检查所需的一切都已具备。下一个阶段是将所有这些整合起来,以便用户可以创建检查报告并将其提交回办公室。在此之后,如果我有时间,将添加 NFC 支持等非关键功能。但是,测试很重要,在我提交之前,我需要时间让一些人真正使用这个项目。时间紧迫确实给项目的这一部分带来了压力。所以,我们拭目以待最终结果。现在,请原谅我,我还有个应用程序要完成……
历史
- 2013 年 8 月 19 日 - 首次提交。
- 2013 年 8 月 21 日 - 添加了图像捕获和编辑工作流程的描述和模拟图。
- 2013 年 10 月 25 日 - 添加了项目进展的第一次更新。