diff --git a/.vscode/arb.code-snippets b/.vscode/arb.code-snippets index 597434eb..ac2f8d05 100644 --- a/.vscode/arb.code-snippets +++ b/.vscode/arb.code-snippets @@ -30,7 +30,7 @@ "scope": "json", "prefix": "select", "body": [ - "\"${1:name}\": \"{${2:placeholder}, select, other{$3}}\",", + "\"${1:name}\": \"{${2:placeholder}, select, other{${3:UNKNOWN}}}\",", "\"@$1\": {", "\t\"description\": \"${0:description}\",", "\t\"placeholders\": {", diff --git a/.vscode/settings.json b/.vscode/settings.json index 400e5b53..256c230f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,11 +15,13 @@ "mockito", "possystem", "pubspec", + "reloadable", "reorderable", "sembast", "signin", "sqflite", "sublist", + "syncfusion", "unfocus", "upgrader", "vsync", diff --git a/docs/untranslated.json b/docs/untranslated.json index cd392678..6dcb52de 100644 --- a/docs/untranslated.json +++ b/docs/untranslated.json @@ -6,6 +6,8 @@ "btnDelete", "btnImport", "btnExport", + "singleChoice", + "multiChoices", "totalCount", "searchCount", "dialogDeletionContent", @@ -178,7 +180,28 @@ "transitGSImportError", "transitPreviewImportTitle", "transitImportColumnsCountError", - "transitImportColumnStatus" + "transitImportColumnStatus", + "analysisChartCreate", + "analysisChartNameLabel", + "analysisChartWithTodayLabel", + "analysisChartIgnoreEmptyLabel", + "analysisChartIgnoreEmptyHelper", + "analysisChartRangeLabel", + "analysisChartRangeHelper", + "analysisChartRange", + "analysisChartTypeLabel", + "analysisChartType", + "analysisChartDataPropertiesDivider", + "analysisChartMetricLabel", + "analysisChartMetricHelper", + "analysisChartMetric", + "analysisChartTargetLabel", + "analysisChartTargetHelper", + "analysisChartTargetError", + "analysisChartTarget", + "analysisChartTargetItemLabel", + "analysisChartTargetItemHelper", + "analysisChartTargetItemSelectAll" ], "zh_Hant": [ @@ -189,6 +212,8 @@ "btnDelete", "btnImport", "btnExport", + "singleChoice", + "multiChoices", "totalCount", "searchCount", "dialogDeletionTitle", @@ -425,7 +450,28 @@ "transitGSImportError", "transitPreviewImportTitle", "transitImportColumnsCountError", - "transitImportColumnStatus" + "transitImportColumnStatus", + "analysisChartCreate", + "analysisChartNameLabel", + "analysisChartWithTodayLabel", + "analysisChartIgnoreEmptyLabel", + "analysisChartIgnoreEmptyHelper", + "analysisChartRangeLabel", + "analysisChartRangeHelper", + "analysisChartRange", + "analysisChartTypeLabel", + "analysisChartType", + "analysisChartDataPropertiesDivider", + "analysisChartMetricLabel", + "analysisChartMetricHelper", + "analysisChartMetric", + "analysisChartTargetLabel", + "analysisChartTargetHelper", + "analysisChartTargetError", + "analysisChartTarget", + "analysisChartTargetItemLabel", + "analysisChartTargetItemHelper", + "analysisChartTargetItemSelectAll" ], "zh_Hant_TW": [ @@ -436,6 +482,8 @@ "btnDelete", "btnImport", "btnExport", + "singleChoice", + "multiChoices", "totalCount", "searchCount", "dialogDeletionTitle", @@ -672,7 +720,28 @@ "transitGSImportError", "transitPreviewImportTitle", "transitImportColumnsCountError", - "transitImportColumnStatus" + "transitImportColumnStatus", + "analysisChartCreate", + "analysisChartNameLabel", + "analysisChartWithTodayLabel", + "analysisChartIgnoreEmptyLabel", + "analysisChartIgnoreEmptyHelper", + "analysisChartRangeLabel", + "analysisChartRangeHelper", + "analysisChartRange", + "analysisChartTypeLabel", + "analysisChartType", + "analysisChartDataPropertiesDivider", + "analysisChartMetricLabel", + "analysisChartMetricHelper", + "analysisChartMetric", + "analysisChartTargetLabel", + "analysisChartTargetHelper", + "analysisChartTargetError", + "analysisChartTarget", + "analysisChartTargetItemLabel", + "analysisChartTargetItemHelper", + "analysisChartTargetItemSelectAll" ], "zh_TW": [ @@ -683,6 +752,8 @@ "btnDelete", "btnImport", "btnExport", + "singleChoice", + "multiChoices", "totalCount", "searchCount", "dialogDeletionTitle", @@ -919,6 +990,27 @@ "transitGSImportError", "transitPreviewImportTitle", "transitImportColumnsCountError", - "transitImportColumnStatus" + "transitImportColumnStatus", + "analysisChartCreate", + "analysisChartNameLabel", + "analysisChartWithTodayLabel", + "analysisChartIgnoreEmptyLabel", + "analysisChartIgnoreEmptyHelper", + "analysisChartRangeLabel", + "analysisChartRangeHelper", + "analysisChartRange", + "analysisChartTypeLabel", + "analysisChartType", + "analysisChartDataPropertiesDivider", + "analysisChartMetricLabel", + "analysisChartMetricHelper", + "analysisChartMetric", + "analysisChartTargetLabel", + "analysisChartTargetHelper", + "analysisChartTargetError", + "analysisChartTarget", + "analysisChartTargetItemLabel", + "analysisChartTargetItemHelper", + "analysisChartTargetItemSelectAll" ] } diff --git a/lib/components/mixin/item_modal.dart b/lib/components/mixin/item_modal.dart index 8bc1ea8d..e9a560f4 100644 --- a/lib/components/mixin/item_modal.dart +++ b/lib/components/mixin/item_modal.dart @@ -9,20 +9,6 @@ mixin ItemModal on State { String get title; - Widget buildBody() { - final fields = buildFormFields() - .expand((field) => [field, const SizedBox(height: kSpacing2)]) - .toList(); - fields.removeLast(); - - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(kSpacing3), - child: Center(child: buildForm(fields)), - ), - ); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -37,15 +23,15 @@ mixin ItemModal on State { ), ], ), - body: buildBody(), + body: buildForm(), ); } - Widget buildForm(List fields) { - return Form( - key: formKey, - child: Column( - children: fields, + Widget buildForm() { + return SingleChildScrollView( + child: Form( + key: formKey, + child: Column(children: buildFormFields()), ), ); } @@ -53,6 +39,11 @@ mixin ItemModal on State { /// Fields in form List buildFormFields(); + /// Handle submission from input field (e.g. onFieldSubmitted) + void handleFieldSubmit(String _) { + handleSubmit(); + } + /// Handle user submission Future handleSubmit() async { if (isSaving || !_validate()) return; @@ -60,6 +51,7 @@ mixin ItemModal on State { await updateItem(); } + /// Update item implementation, called when the form is valid Future updateItem(); bool _validate() { @@ -73,4 +65,12 @@ mixin ItemModal on State { return true; } + + /// Padding widget + Widget p(Widget child) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: kSpacing3), + child: child, + ); + } } diff --git a/lib/components/scrollable_draggable_sheet.dart b/lib/components/scrollable_draggable_sheet.dart index d7bc08b0..8344d580 100644 --- a/lib/components/scrollable_draggable_sheet.dart +++ b/lib/components/scrollable_draggable_sheet.dart @@ -47,38 +47,28 @@ class _ScrollableDraggableSheetState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { - final willPop = controller.snapIndex.value == 0; - if (!willPop) { - controller.reset(); - } - - return willPop; + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + controller.transferSnapSizes( + constraints.biggest.height, + widget.margin.vertical, + ); + + return DraggableScrollableSheet( + controller: controller, + initialChildSize: controller.snapSizes[widget.initSnapIndex], + minChildSize: controller.minSnap, + maxChildSize: controller.maxSnap, + expand: true, + snap: true, + snapSizes: controller.snapSizes, + shouldCloseOnMinExtent: true, + builder: (_, scrollController) { + scroll = scrollController; + return content; + }, + ); }, - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - controller.transferSnapSizes( - constraints.biggest.height, - widget.margin.vertical, - ); - - return DraggableScrollableSheet( - controller: controller, - initialChildSize: controller.snapSizes[widget.initSnapIndex], - minChildSize: controller.minSnap, - maxChildSize: controller.maxSnap, - expand: true, - snap: true, - snapSizes: controller.snapSizes, - shouldCloseOnMinExtent: true, - builder: (_, scrollController) { - scroll = scrollController; - return content; - }, - ); - }, - ), ); } @@ -129,18 +119,27 @@ class _ScrollableDraggableSheetState extends State { if (value == 1.0) { scrollable.value = true; } - return Card( - shape: value == 1.0 - ? const RoundedRectangleBorder(borderRadius: BorderRadius.zero) - : const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(16.0), + return PopScope( + canPop: controller.snapIndex.value == 0, + onPopInvoked: (popped) { + if (!popped) { + controller.reset(); + } + }, + child: Card( + shape: value == 1.0 + ? const RoundedRectangleBorder( + borderRadius: BorderRadius.zero) + : const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16.0), + ), ), - ), - clipBehavior: Clip.hardEdge, - elevation: 2.0, - margin: value == 1.0 ? const EdgeInsets.all(0) : widget.margin, - child: child, + clipBehavior: Clip.hardEdge, + elevation: 2.0, + margin: value == 1.0 ? const EdgeInsets.all(0) : widget.margin, + child: child, + ), ); }, child: Column( diff --git a/lib/components/style/outlined_text.dart b/lib/components/style/outlined_text.dart index c8d40c89..2f2e10d8 100644 --- a/lib/components/style/outlined_text.dart +++ b/lib/components/style/outlined_text.dart @@ -56,7 +56,7 @@ class OutlinedText extends StatelessWidget { const EdgeInsets.symmetric(horizontal: 16), const EdgeInsets.symmetric(horizontal: 8), const EdgeInsets.symmetric(horizontal: 4), - MediaQuery.maybeOf(context)?.textScaleFactor ?? 1, + MediaQuery.textScalerOf(context).scale(1), ); final base = ConstrainedBox( diff --git a/lib/components/style/percentile_bar.dart b/lib/components/style/percentile_bar.dart index 589dec23..3525f979 100644 --- a/lib/components/style/percentile_bar.dart +++ b/lib/components/style/percentile_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:possystem/helpers/util.dart'; class PercentileBar extends StatefulWidget { final num total; @@ -28,9 +29,7 @@ class _PercentileBarState extends State Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - Text(_toString(widget.at)), - const Text('/'), - Text(_toString(widget.total)), + Text('${widget.at.prettyString()}/${widget.total.prettyString()}'), ], ), AnimatedBuilder( @@ -99,15 +98,3 @@ class _PercentileBarState extends State super.dispose(); } } - -/// Maximum 4 characters -String _toString(num v) { - if (v is int || v == v.ceil()) { - if (v < 10000) { - return v.toStringAsFixed(0); - } - } else if (v < 1000) { - return v.toStringAsFixed(1); - } - return v.toStringAsExponential(1); -} diff --git a/lib/components/style/snackbar.dart b/lib/components/style/snackbar.dart index 0d23fa2b..8dbca013 100644 --- a/lib/components/style/snackbar.dart +++ b/lib/components/style/snackbar.dart @@ -48,11 +48,13 @@ void showMoreInfoSnackBar( onPressed: () { showDialog( context: context, - builder: (context) => SimpleDialog( - title: Text(message), - contentPadding: const EdgeInsets.fromLTRB(8, 12, 8, 16), - children: [content], - ), + builder: (context) { + return SimpleDialog( + title: Text(message), + contentPadding: const EdgeInsets.fromLTRB(8, 12, 8, 16), + children: [content], + ); + }, ); }, ), diff --git a/lib/debug/random_gen_order.dart b/lib/debug/random_gen_order.dart index da26fa48..62ae0294 100644 --- a/lib/debug/random_gen_order.dart +++ b/lib/debug/random_gen_order.dart @@ -226,7 +226,7 @@ class _SettingPageState extends State<_SettingPage> { ); await Future.forEach(result, (e) => seller.push(e)); - if (context.mounted) { + if (mounted) { showSnackBar(context, '成功產生 ${result.length} 個訂單'); Navigator.of(context).pop(); diff --git a/lib/helpers/analysis/ema_calculator.dart b/lib/helpers/analysis/ema_calculator.dart new file mode 100644 index 00000000..1962fe25 --- /dev/null +++ b/lib/helpers/analysis/ema_calculator.dart @@ -0,0 +1,25 @@ +class EMACalculator { + final double weightFactor; + + final int length; + + const EMACalculator(this.length) : weightFactor = 2 / (length + 1); + + double calculate(Iterable data) { + double carry = 0; + + for (final value in data) { + carry = feed(value, carry); + } + + return carry; + } + + double feed(num value, double carry) { + if (carry == 0) { + return value.toDouble(); + } + + return value * weightFactor + carry * (1 - weightFactor); + } +} diff --git a/lib/helpers/util.dart b/lib/helpers/util.dart index 61334048..92559265 100644 --- a/lib/helpers/util.dart +++ b/lib/helpers/util.dart @@ -61,3 +61,17 @@ class Util { }; } } + +extension PrettyNum on num { + /// Maximum 4 characters + String prettyString() { + if (this is int || this == ceil()) { + if (this < 10000) { + return toStringAsFixed(0); + } + } else if (this < 1000) { + return toStringAsFixed(1); + } + return toStringAsExponential(1); + } +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f0a600a4..dcd169b7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,5 @@ { + "@@locale": "zh", "appTitle": "POS 系統", "actSuccess": "執行成功", "@actSuccess": { @@ -18,6 +19,8 @@ }, "btnImport": "匯入", "btnExport": "匯出", + "singleChoice": "一次只能選擇一種", + "multiChoices": "可以選擇多種", "totalCount": "總共 {count} 項", "@totalCount": { "description": "顯示於 ListView 上的數量總數", @@ -457,7 +460,7 @@ "stockIngredientNameRepeatError": "成分名稱重複", "stockIngredientAmountLabel": "現有庫存", "stockIngredientTotalAmountLabel": "庫存最大值", - "stockIngredientTotalAmountHelper": "填空則重設。\n設定這個值可以幫助你一眼看出用了多少成份。\n不填寫則每次「增加庫存」會自動設定這值", + "stockIngredientTotalAmountHelper": "填空則重設。\n設定這個值可以幫助你一眼看出用了多少成分。\n不填寫則每次「增加庫存」會自動設定這值", "stockReplenishmentTitle": "採購列表", "stockReplenishmentSubtitle": "會影響 {count} 項成分", "@stockReplenishmentSubtitle": { @@ -882,7 +885,7 @@ "transitPreviewExportTitle": "預覽輸出結果", "transitBasicTitle": "POS System 資料", "transitOrderTitle": "訂單資料", - "transitType": "{label, select, menu{菜單} stock{庫存} quantities{份量} replenisher{補貨} orderAttr{顧客設定} order{訂單} orderSetAttr{訂單顧客設定} orderProduct{訂單產品細項} orderIngredient{訂單成份細項} other{UNKNOWN}}", + "transitType": "{label, select, menu{菜單} stock{庫存} quantities{份量} replenisher{補貨} orderAttr{顧客設定} order{訂單} orderSetAttr{訂單顧客設定} orderProduct{訂單產品細項} orderIngredient{訂單成分細項} other{UNKNOWN}}", "@transitType": { "description": "各種匯出資料的名稱,這會變成預設的表單名稱", "placeholders": { @@ -920,7 +923,7 @@ } } }, - "transitGSUpdateModelStatus": "{model, select, menu{更新菜單中..} stock{更新庫存中..} quantities{更新份量中..} replenisher{更新補貨中..} orderAttr{更新顧客設定中..} order{匯出訂單中..} orderSetAttr{匯出顧客設定中..} orderProduct{匯出產品細項中..} orderIngredient{匯出成份細項中..} other{{model}}}", + "transitGSUpdateModelStatus": "{model, select, menu{更新菜單中..} stock{更新庫存中..} quantities{更新份量中..} replenisher{更新補貨中..} orderAttr{更新顧客設定中..} order{匯出訂單中..} orderSetAttr{匯出顧客設定中..} orderProduct{匯出產品細項中..} orderIngredient{匯出成分細項中..} other{{model}}}", "@transitGSUpdateModelStatus": { "description": "更新 Model 時的狀態說明", "placeholders": { @@ -929,11 +932,11 @@ } } }, - "transitProductIngredientInfoTitle": "成份資訊", + "transitProductIngredientInfoTitle": "成分資訊", "transitReplenishmentTitle": "補貨量", "transitOrderAttributeOptionTitle": "顧客設定選項", - "transitProductIngredientInfoGSNote": "產品全部成份的資訊,格式如下:\n- 成份1,預設使用量\n + 份量a,額外使用量,額外價格,額外成本\n + 份量b,額外使用量,額外價格,額外成本\n- 成份2,預設使用量", - "transitReplenishmentGSNote": "每次補貨時特定成份的量,格式如下:\n- 成份1,補貨量\n- 成份2,補貨量", + "transitProductIngredientInfoGSNote": "產品全部成分的資訊,格式如下:\n- 成分1,預設使用量\n + 份量a,額外使用量,額外價格,額外成本\n + 份量b,額外使用量,額外價格,額外成本\n- 成分2,預設使用量", + "transitReplenishmentGSNote": "每次補貨時特定成分的量,格式如下:\n- 成分1,補貨量\n- 成分2,補貨量", "transitOrderAttributeOptionGSNote": "「選項值」會根據顧客設定種類不同而有不同意義,格式如下:\n- 選項1,是否為預設,選項值\n- 選項2,是否為預設,選項值", "transitGSImportError": "{error, select, emptySpreadsheet{必須選擇試算表來匯入} emptySheet{必須選擇指定的表單來匯入} emptyData{在表單中沒找到任何值} other{{error}}}", "@transitGSImportError": { @@ -962,5 +965,54 @@ "type": "String" } } - } + }, + "analysisChartCreate": "新增圖表", + "analysisChartNameLabel": "圖表標題", + "analysisChartWithTodayLabel": "資料是否包含今日", + "analysisChartIgnoreEmptyLabel": "是否忽略空資料", + "analysisChartIgnoreEmptyHelper": "某商品或指標在該時段沒有資料,則不顯示。", + "analysisChartRangeLabel": "時間區間", + "analysisChartRangeHelper": "長時間可以看到趨勢,短時間可以看到變化。", + "analysisChartRange": "{val, select, today{單日} sevenDays{七日} twoWeeks{雙週} month{單月} twoMonths{雙月} halfYear{半年} year{一年} other{UNKNOWN}}", + "@analysisChartRange": { + "placeholders": { + "val": { + "type": "String" + } + } + }, + "analysisChartTypeLabel": "圖表類型", + "analysisChartType": "{val, select, cartesian{時序圖} circular{圓餅圖} other{UNKNOWN}}", + "@analysisChartType": { + "placeholders": { + "val": { + "type": "String" + } + } + }, + "analysisChartDataPropertiesDivider": "圖表資料", + "analysisChartMetricLabel": "觀看指標", + "analysisChartMetricHelper": "根據不同目的,選擇不同指標類型。", + "analysisChartMetric": "{val, select, price{營收} cost{成本} revenue{淨利} count{數量/份量} other{UNKNOWN}}", + "@analysisChartMetric": { + "placeholders": { + "val": { + "type": "String" + } + } + }, + "analysisChartTargetLabel": "項目種類", + "analysisChartTargetHelper": "選擇圖表中要針對哪些資訊做分析。", + "analysisChartTargetError": "請選擇一個項目種類", + "analysisChartTarget": "{val, select, order{訂單} catalog{產品種類} product{產品} ingredient{成分} attribute{顧客屬性} other{UNKNOWN}}", + "@analysisChartTarget": { + "placeholders": { + "val": { + "type": "String" + } + } + }, + "analysisChartTargetItemLabel": "項目選擇", + "analysisChartTargetItemHelper": "你想要觀察哪些項目的變化,例如區間內某商品的數量。", + "analysisChartTargetItemSelectAll": "全選" } diff --git a/lib/main.dart b/lib/main.dart index feafc864..b5d08c3a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:firebase_in_app_messaging/firebase_in_app_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:possystem/models/analysis/analysis.dart'; import 'package:possystem/models/repository/cart.dart'; import 'package:provider/provider.dart'; @@ -62,6 +63,7 @@ void main() async { await OrderAttributes().initialize(); await Replenisher().initialize(); await Cashier().reset(); + await Analysis().initialize(); // Last for setup ingredient and quantity await Menu().initialize(); diff --git a/lib/models/analysis/analysis.dart b/lib/models/analysis/analysis.dart new file mode 100644 index 00000000..eebb5c97 --- /dev/null +++ b/lib/models/analysis/analysis.dart @@ -0,0 +1,102 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:possystem/models/analysis/chart.dart'; +import 'package:possystem/models/analysis/chart_object.dart'; +import 'package:possystem/models/model.dart'; +import 'package:possystem/models/model_object.dart'; +import 'package:possystem/models/repository.dart'; +import 'package:possystem/models/repository/seller.dart'; +import 'package:possystem/services/storage.dart'; + +class Analysis extends ChangeNotifier + with Repository, RepositoryStorage { + static late Analysis instance; + + @override + final Stores storageStore = Stores.analysis; + + Analysis() { + instance = this; + } + + @override + Chart buildItem(String id, Map value) { + final type = AnalysisChartType.values[(value['type'] as int?) ?? 0]; + final object = ChartObject.build({'id': id, ...value}); + + switch (type) { + case AnalysisChartType.cartesian: + return CartesianChart.fromObject(object); + case AnalysisChartType.circular: + return CircularChart.fromObject(object); + } + } +} + +abstract class Chart extends Model + with ModelStorage { + abstract final AnalysisChartType type; + + /// Which range to show, for example 7 days, 30 days, or 365 days + OrderChartRange range; + + /// Whether show today's data + bool withToday; + + /// Whether ignore empty data + bool ignoreEmpty; + + /// Which target to show, product, category, or ingredients + OrderMetricTarget target; + + /// Which metrics to show, price, cost, or revenue + List metrics; + + /// Target's specified items IDs. + List targetItems; + + Chart({ + String? id, + required String name, + required ModelStatus status, + required this.range, + required this.withToday, + required this.ignoreEmpty, + required this.metrics, + required this.target, + required this.targetItems, + }) : super(id, name, status); + + @override + Stores get storageStore => Stores.analysis; + + @override + Analysis get repository => Analysis.instance; + + @override + set repository(Repository repo) {} + + @override + ChartObject toObject() { + return ChartObject( + type: type, + id: id, + name: name, + range: range, + withToday: withToday, + ignoreEmpty: ignoreEmpty, + target: target, + metrics: metrics, + targetItems: targetItems, + ); + } + + /// Load the metrics from the database + Future> loader(DateTime start, DateTime end); + + Iterable get units { + return metrics + .groupFoldBy((e) => e.unit, (prev, current) => 0) + .keys; + } +} diff --git a/lib/models/analysis/chart.dart b/lib/models/analysis/chart.dart new file mode 100644 index 00000000..c2c8d841 --- /dev/null +++ b/lib/models/analysis/chart.dart @@ -0,0 +1,113 @@ +import 'package:possystem/models/analysis/chart_object.dart'; +import 'package:possystem/models/analysis/analysis.dart'; +import 'package:possystem/models/model.dart'; +import 'package:possystem/models/repository/seller.dart'; + +enum AnalysisChartType { cartesian, circular } + +class CartesianChart extends Chart { + @override + final AnalysisChartType type = AnalysisChartType.cartesian; + + CartesianChart({ + super.id, + super.status = ModelStatus.normal, + super.name = 'cartesian', + super.range = OrderChartRange.sevenDays, + super.withToday = false, + super.ignoreEmpty = true, + super.target = OrderMetricTarget.order, + super.metrics = const [OrderMetricType.price], + super.targetItems = const [], + }); + + factory CartesianChart.fromObject(ChartObject object) { + return CartesianChart( + id: object.id, + name: object.name ?? 'cartesian', + range: object.range ?? OrderChartRange.sevenDays, + withToday: object.withToday ?? false, + ignoreEmpty: object.ignoreEmpty ?? true, + target: object.target ?? OrderMetricTarget.order, + metrics: object.metrics ?? const [OrderMetricType.price], + targetItems: object.targetItems ?? const [], + ); + } + + @override + Future> loader(DateTime start, DateTime end) { + return target == OrderMetricTarget.order + ? Seller.instance.getMetricsInPeriod( + start, + end, + types: metrics, + period: range.period, + ignoreEmpty: ignoreEmpty, + ) + : Seller.instance.getItemMetricsInPeriod( + start, + end, + target: target, + type: metrics.first, + selection: targetItems, + period: range.period, + ignoreEmpty: ignoreEmpty, + ); + } + + Iterable> keyUnits() { + if (target == OrderMetricTarget.order) { + return metrics.map((e) => MapEntry(e.name, e.unit)); + } + + final unit = metrics.first.unit; + return target + .getItems(targetItems) + .map(target.isGroupedName(targetItems) + ? (e) => '${e.name}(${(e.repository as Model).name})' + : (e) => e.name) + .map((e) => MapEntry(e, unit)); + } +} + +class CircularChart extends Chart { + @override + final AnalysisChartType type = AnalysisChartType.circular; + + CircularChart({ + super.id, + super.status = ModelStatus.normal, + super.name = 'circular', + super.range = OrderChartRange.sevenDays, + super.withToday = false, + super.ignoreEmpty = true, + super.target = OrderMetricTarget.catalog, + super.metrics = const [OrderMetricType.count], + super.targetItems = const [], + }); + + factory CircularChart.fromObject(ChartObject object) { + return CircularChart( + id: object.id, + name: object.name ?? 'circular', + range: object.range ?? OrderChartRange.sevenDays, + withToday: object.withToday ?? false, + ignoreEmpty: object.ignoreEmpty ?? true, + target: object.target ?? OrderMetricTarget.catalog, + metrics: object.metrics ?? const [OrderMetricType.count], + targetItems: object.targetItems ?? const [], + ); + } + + @override + Future> loader(DateTime start, DateTime end) async { + return Seller.instance.getMetricsByItems( + start, + end, + target: target, + type: metrics.first, + selection: targetItems, + ignoreEmpty: ignoreEmpty, + ); + } +} diff --git a/lib/models/analysis/chart_object.dart b/lib/models/analysis/chart_object.dart new file mode 100644 index 00000000..180e9e53 --- /dev/null +++ b/lib/models/analysis/chart_object.dart @@ -0,0 +1,95 @@ +import 'package:possystem/models/analysis/analysis.dart'; +import 'package:possystem/models/analysis/chart.dart'; +import 'package:possystem/models/model_object.dart'; +import 'package:possystem/models/repository/seller.dart'; + +class ChartObject extends ModelObject { + final AnalysisChartType type; + final String? id; + final String? name; + final OrderChartRange? range; + final bool? withToday; + final bool? ignoreEmpty; + final OrderMetricTarget? target; + final List? metrics; + final List? targetItems; + + const ChartObject({ + this.type = AnalysisChartType.cartesian, + this.id, + this.name, + this.range, + this.withToday, + this.ignoreEmpty, + this.target, + this.metrics, + this.targetItems, + }); + + factory ChartObject.build(Map map) { + return ChartObject( + id: map['id'] as String?, + name: map['name'] as String?, + range: OrderChartRange.values[map['range'] as int? ?? 0], + withToday: map['withToday'] as bool?, + ignoreEmpty: map['ignoreEmpty'] as bool?, + target: OrderMetricTarget.values[map['target'] as int? ?? 0], + metrics: (map['metrics'] as List?) + ?.map((e) => OrderMetricType.values[e as int]) + .toList(), + targetItems: map['targetItems'] as List?, + ); + } + + @override + Map toMap() { + return { + 'type': type.index, + 'name': name, + 'range': range?.index, + 'withToday': withToday, + 'ignoreEmpty': ignoreEmpty, + 'target': target?.index, + 'metrics': metrics?.map((e) => e.index).toList(), + 'targetItems': targetItems, + }; + } + + @override + Map diff(T model) { + final result = {}; + final prefix = model.prefix; + + if (name != null && name != model.name) { + model.name = name!; + result['$prefix.name'] = name!; + } + if (metrics != null && metrics!.join('') != model.metrics.join('')) { + model.metrics = metrics!; + result['$prefix.metrics'] = metrics!.map((e) => e.index).toList(); + } + if (target != null && target != model.target) { + model.target = target!; + result['$prefix.target'] = target!.index; + } + if (targetItems != null && + targetItems!.join('') != model.targetItems.join('')) { + model.targetItems = targetItems!; + result['$prefix.targetItems'] = targetItems!; + } + if (withToday != null && withToday != model.withToday) { + model.withToday = withToday!; + result['$prefix.withToday'] = withToday!; + } + if (ignoreEmpty != null && ignoreEmpty != model.ignoreEmpty) { + model.ignoreEmpty = ignoreEmpty!; + result['$prefix.ignoreEmpty'] = ignoreEmpty!; + } + if (range != null && range != model.range) { + model.range = range!; + result['$prefix.range'] = range!.index; + } + + return result; + } +} diff --git a/lib/models/menu/catalog.dart b/lib/models/menu/catalog.dart index 8dd305f0..b0739f72 100644 --- a/lib/models/menu/catalog.dart +++ b/lib/models/menu/catalog.dart @@ -25,15 +25,14 @@ class Catalog extends Model Catalog({ String? id, - ModelStatus? status, + ModelStatus status = ModelStatus.normal, String name = 'catalog', int index = 0, String? imagePath, DateTime? createdAt, Map? products, }) : createdAt = createdAt ?? DateTime.now(), - super(id, status) { - this.name = name; + super(id, name, status) { this.index = index; this.imagePath = imagePath; if (products != null) replaceItems(products); diff --git a/lib/models/menu/product.dart b/lib/models/menu/product.dart index 58f016e4..649dc998 100644 --- a/lib/models/menu/product.dart +++ b/lib/models/menu/product.dart @@ -40,7 +40,7 @@ class Product extends Model Product({ String? id, - ModelStatus? status, + ModelStatus status = ModelStatus.normal, String name = 'product', int index = 1, this.cost = 0, @@ -50,7 +50,7 @@ class Product extends Model this.searchedAt, Map? ingredients, }) : createdAt = createdAt ?? DateTime.now(), - super(id, status) { + super(id, name, status) { this.name = name; this.index = index; this.imagePath = imagePath; diff --git a/lib/models/menu/product_ingredient.dart b/lib/models/menu/product_ingredient.dart index aea9331a..e649d521 100644 --- a/lib/models/menu/product_ingredient.dart +++ b/lib/models/menu/product_ingredient.dart @@ -32,11 +32,11 @@ class ProductIngredient extends Model ProductIngredient({ String? id, - ModelStatus? status, + ModelStatus status = ModelStatus.normal, Ingredient? ingredient, this.amount = 0, Map? quantities, - }) : super(id, status) { + }) : super(id, '', status) { if (quantities != null) replaceItems(quantities); if (ingredient != null) this.ingredient = ingredient; diff --git a/lib/models/menu/product_quantity.dart b/lib/models/menu/product_quantity.dart index a62338dc..8d15566e 100644 --- a/lib/models/menu/product_quantity.dart +++ b/lib/models/menu/product_quantity.dart @@ -31,12 +31,12 @@ class ProductQuantity extends Model ProductQuantity({ String? id, - ModelStatus? status, + ModelStatus status = ModelStatus.normal, Quantity? quantity, this.amount = 0, this.additionalCost = 0, this.additionalPrice = 0, - }) : super(id, status) { + }) : super(id, '', status) { if (quantity != null) this.quantity = quantity; } diff --git a/lib/models/model.dart b/lib/models/model.dart index 2c5d470f..6d9d8fc8 100644 --- a/lib/models/model.dart +++ b/lib/models/model.dart @@ -15,16 +15,15 @@ enum ModelStatus { } abstract class Model extends ChangeNotifier { - late String id; + String id; - late String name; + String name; - // 是否是暫存的資料,並未存進檔案系統中,僅存在於記憶體中。 - late ModelStatus status; + /// 是否是暫存的資料,並未存進檔案系統中,僅存在於記憶體中。 + ModelStatus status; - Model(String? id, ModelStatus? status) - : id = id ?? Util.uuidV4(), - status = status ?? ModelStatus.normal; + Model([String? id, this.name = '', this.status = ModelStatus.normal]) + : id = id ?? Util.uuidV4(); String get logName; diff --git a/lib/models/order/order_attribute.dart b/lib/models/order/order_attribute.dart index fa8b0017..4d5904f5 100644 --- a/lib/models/order/order_attribute.dart +++ b/lib/models/order/order_attribute.dart @@ -22,13 +22,12 @@ class OrderAttribute extends Model OrderAttribute({ String? id, - ModelStatus? status, + ModelStatus status = ModelStatus.normal, String name = 'order attribute', int index = 0, this.mode = OrderAttributeMode.statOnly, Map? options, - }) : super(id, status) { - this.name = name; + }) : super(id, name, status) { this.index = index; if (options != null) replaceItems(options); } diff --git a/lib/models/order/order_attribute_option.dart b/lib/models/order/order_attribute_option.dart index d11883e7..7e760c0f 100644 --- a/lib/models/order/order_attribute_option.dart +++ b/lib/models/order/order_attribute_option.dart @@ -19,13 +19,12 @@ class OrderAttributeOption extends Model OrderAttributeOption({ String? id, - ModelStatus? status, + ModelStatus status = ModelStatus.normal, String name = 'order attribute option', int index = 0, this.isDefault = false, this.modeValue, - }) : super(id, status) { - this.name = name; + }) : super(id, name, status) { this.index = index; } diff --git a/lib/models/repository/seller.dart b/lib/models/repository/seller.dart index 1e56cbf2..c32b6358 100644 --- a/lib/models/repository/seller.dart +++ b/lib/models/repository/seller.dart @@ -1,8 +1,14 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:possystem/helpers/util.dart'; +import 'package:possystem/models/model.dart'; import 'package:possystem/models/objects/order_object.dart'; +import 'package:possystem/models/order/order_attribute_option.dart'; +import 'package:possystem/models/repository/menu.dart'; +import 'package:possystem/models/repository/order_attributes.dart'; +import 'package:possystem/models/repository/stock.dart'; import 'package:possystem/services/database.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; /// Help I/O from order DB. class Seller extends ChangeNotifier { @@ -19,30 +25,7 @@ class Seller extends ChangeNotifier { Seller._(); - /// Get the count of orders per day. - Future> getCountPerDay( - DateTime start, - DateTime end, - ) async { - final begin = Util.toUTC(now: start); - final cease = Util.toUTC(now: end); - // using UTC to calculate the count but use user's timezone when returned. - final rows = await Database.instance.query( - '(SELECT CAST((createdAt - $begin) / 86400 AS INT) day FROM $orderTable ' - 'WHERE createdAt BETWEEN $begin AND $cease) t', - columns: ['t.day', 'COUNT(*) c'], - groupBy: "t.day", - escapeTable: false, - ); - - return { - for (final row in rows) - if (row['day'] != null) - Util.fromUTC(begin + (row['day'] as int) * 86400): row['c'] as int - }; - } - - /// Get the metrics of orders from time range. + /// Get the metrics(e.g. count, price) of orders from time range. Future getMetrics( DateTime start, DateTime end, { @@ -114,6 +97,170 @@ class Seller extends ChangeNotifier { } } + /// Get the metric of orders grouped by the day. + /// + /// - [types] is the metrics type to calculate. + /// - [period] is the time interval to group by. + /// - [ignoreEmpty] whether to ignore the empty day. + Future> getMetricsInPeriod( + DateTime start, + DateTime end, { + List types = const [OrderMetricType.count], + MetricsPeriod period = MetricsPeriod.day, + bool ignoreEmpty = true, + }) async { + // using UTC to calculate the count but use user's timezone when returned. + final begin = Util.toUTC(now: start); + final cease = Util.toUTC(now: end); + + final rows = await Database.instance.query( + '(' + 'SELECT CAST((createdAt - $begin) / ${period.seconds} AS INT) day, * ' + 'FROM $orderTable ' + 'WHERE createdAt BETWEEN $begin AND $cease' + ') t', + columns: [ + 't.day', + ...types.map((e) => '${e.method}(t.${e.column}) ${e.name}'), + ], + groupBy: "t.day", + orderBy: "t.day asc", + escapeTable: false, + ); + + final result = [ + for (final row in rows) + if (row['day'] != null) + OrderDataPerDay( + at: Util.fromUTC(begin + (row['day'] as int) * period.seconds), + values: row.cast(), + ), + ]; + + return ignoreEmpty + ? result + : _fulfillPeriodData( + start, end, Duration(seconds: period.seconds), result); + } + + /// Get the metric of items grouped by the day. + /// + /// - [target] is the target of catalog to group by. + /// - [period] is the time interval to group by. + /// - [selection] is the specific items to group by. + /// - [ignoreEmpty] whether to ignore the empty day. + Future> getItemMetricsInPeriod( + DateTime start, + DateTime end, { + required OrderMetricType type, + required OrderMetricTarget target, + MetricsPeriod period = MetricsPeriod.day, + List selection = const [], + bool ignoreEmpty = true, + }) async { + // using UTC to calculate the count but use user's timezone when returned. + final begin = Util.toUTC(now: start); + final cease = Util.toUTC(now: end); + + final where = selection.isEmpty + ? '' + : ' AND ${target.filterColumn} IN ("${selection.join('","')}")'; + // if target has different column then we need to concat the column to + // make the result more readable. + // (different catalog may have same item name). + // take order attribute as example: + // plasticSpoon(yes), withBag(yes) both have same attribute: `yes` + final name = target.isGroupedName(selection) + ? "`${target.groupColumn}` || '(' || `${target.filterColumn}` || ')'" + : target.groupColumn; + + final rows = await Database.instance.query( + '(' + 'SELECT CAST((createdAt - $begin) / ${period.seconds} AS INT) day, * ' + 'FROM ${target.table} ' + 'WHERE createdAt BETWEEN $begin AND $cease $where ' + ') t', + columns: [ + 't.day', + '$name name', + '${type.method}(${type.targetColumn}) value', + ], + groupBy: "t.day, ${target.groupColumn}", + orderBy: "t.day asc", + escapeTable: false, + ); + + final result = rows + .where((e) => e['day'] != null) + .groupListsBy((row) => row['day']) + .values + .map((e) => OrderDataPerDay( + at: Util.fromUTC( + begin + (e.first['day'] as int) * period.seconds), + values: { + for (final row in e) row['name'] as String: row['value'] as num, + }, + )) + .toList(); + + return ignoreEmpty + ? result + : _fulfillPeriodData( + start, end, Duration(seconds: period.seconds), result); + } + + /// Get the metrics of orders and group by the items. + /// + /// select all if [selection] is empty. + Future> getMetricsByItems( + DateTime start, + DateTime end, { + required OrderMetricType type, + required OrderMetricTarget target, + List selection = const [], + bool ignoreEmpty = false, + }) async { + final begin = Util.toUTC(now: start); + final cease = Util.toUTC(now: end); + + final where = selection.isEmpty + ? '' + : ' AND ${target.filterColumn} IN ("${selection.join('","')}")'; + + final rows = await Database.instance.query( + target.table, + columns: [ + '${target.groupColumn} name', + '${type.method}(${type.targetColumn}) value', + ], + where: 'createdAt BETWEEN ? AND ?$where', + whereArgs: [begin, cease], + groupBy: target.groupColumn, + orderBy: 'count desc', + ); + + final total = rows.fold(0.0, (prev, e) => prev + (e['value'] as num)); + final result = [ + for (final row in rows) + OrderMetricPerItem( + row['name'] as String, + row['value'] as num, + total, + ), + ]; + + if (ignoreEmpty) { + return result; + } + + return target + .getItems(selection) + .map((item) => + result.where((e) => e.name == item.name).firstOrNull ?? + OrderMetricPerItem(item.name, 0, total)) + .toList(); + } + /// Get orders and its products info from time range. Future> getOrders( DateTime start, @@ -284,6 +431,22 @@ class Seller extends ChangeNotifier { } return items.length; } + + List _fulfillPeriodData( + DateTime start, + DateTime end, + Duration interval, + List data, + ) { + var i = 0; + return [ + for (var v = start; v.isBefore(end); v = v.add(interval)) + // `result is not enough` or `result has not contains the day` + i >= data.length || data[i].at != v + ? OrderDataPerDay(at: v) + : data[i++], + ]; + } } /// Metrics from [Seller.getMetrics] @@ -340,3 +503,170 @@ class OrderMetrics { ); } } + +class OrderDataPerDay { + final DateTime at; + + final Map values; + + const OrderDataPerDay({ + required this.at, + this.values = const {}, + }); + + num value(String key) { + return values[key] ?? 0; + } + + int get count => value('count').toInt(); + + num get price => value('price'); + + num get cost => value('cost'); + + num get revenue => value('revenue'); +} + +class OrderMetricPerItem { + final String name; + final num value; + final double percent; + + OrderMetricPerItem(this.name, this.value, num total) + : percent = total == 0 ? 0 : (value / total * 100).toDouble(); +} + +enum OrderMetricUnit { + money(r'${value}', r'$point.y'), + count(r'{value}', r'point.y'); + + final String labelFormat; + final String tooltipFormat; + + const OrderMetricUnit(this.labelFormat, this.tooltipFormat); +} + +enum OrderMetricType { + price('SUM', 'price', 't.singlePrice * t.count', OrderMetricUnit.money), + cost('SUM', 'cost', 't.singleCost * t.count', OrderMetricUnit.money), + revenue('SUM', 'revenue', '(t.singlePrice - t.singleCost) * t.count', + OrderMetricUnit.money), + count('COUNT', 'price', '*', OrderMetricUnit.count); + + /// The method to calculate the value in DB. + final String method; + + /// The source column to execute [method]. + final String column; + + /// Target item column. + final String targetColumn; + + /// The unit on chart. + final OrderMetricUnit unit; + + const OrderMetricType( + this.method, + this.column, + this.targetColumn, + this.unit, + ); +} + +enum OrderMetricTarget { + order(Seller.orderTable, '', ''), + catalog(Seller.productTable, 'catalogName', 'catalogName'), + product(Seller.productTable, 'productName', 'productName'), + ingredient(Seller.ingredientTable, 'ingredientName', 'ingredientName'), + attribute(Seller.attributeTable, 'name', 'optionName'); + + /// The table name in DB. + final String table; + + /// The column use on `where` syntax in DB. + final String filterColumn; + + /// The column use on `group` syntax in DB. + final String groupColumn; + + const OrderMetricTarget(this.table, this.filterColumn, this.groupColumn); + + /// Whether the filter column is different from the group column. + bool get hasDifferentColumn => filterColumn != groupColumn; + + /// Whether append parenthesis to the name when grouped. + bool isGroupedName(List selection) => + hasDifferentColumn && selection.length != 1; + + /// Get the items from the target. + /// + /// - [selection] null and empty means select all + List getItems([List? selection]) { + late final List result; + switch (this) { + case OrderMetricTarget.product: + result = Menu.instance.products.toList(); + break; + case OrderMetricTarget.catalog: + result = Menu.instance.itemList; + break; + case OrderMetricTarget.ingredient: + result = Stock.instance.itemList; + break; + case OrderMetricTarget.attribute: + if (selection != null) { + if (selection.isEmpty) { + return OrderAttributes.instance.itemList + .expand((e) => e.itemList) + .toList(); + } + + return selection + .expand((id) => + OrderAttributes.instance.getItem(id)?.itemList ?? const []) + .toList(); + } + + result = OrderAttributes.instance.itemList; + break; + default: + return const []; + } + + // null and empty means select all + if (selection == null || selection.isEmpty) { + return result; + } + + return result.where((e) => selection.contains(e.id)).toList(); + } +} + +enum OrderChartRange { + today(Duration(days: 1), MetricsPeriod.hour), + sevenDays(Duration(days: 7), MetricsPeriod.day), + twoWeeks(Duration(days: 14), MetricsPeriod.day), + month(Duration(days: 30), MetricsPeriod.day), + twoMonths(Duration(days: 60), MetricsPeriod.day), + halfYear(Duration(days: 180), MetricsPeriod.month), + year(Duration(days: 365), MetricsPeriod.month); + + final Duration duration; + + /// The period of the metrics, use to group the data + final MetricsPeriod period; + + const OrderChartRange(this.duration, this.period); +} + +enum MetricsPeriod { + hour(3600, 'HH:mm a', DateTimeIntervalType.hours), + day(86400, 'MMMEd', DateTimeIntervalType.days), + month(2592000, 'MMMd', DateTimeIntervalType.months); + + final int seconds; + final String format; + final DateTimeIntervalType intervalType; + + const MetricsPeriod(this.seconds, this.format, this.intervalType); +} diff --git a/lib/models/stock/ingredient.dart b/lib/models/stock/ingredient.dart index cee3e916..60c2101c 100644 --- a/lib/models/stock/ingredient.dart +++ b/lib/models/stock/ingredient.dart @@ -30,17 +30,15 @@ class Ingredient extends Model Ingredient({ String? id, - ModelStatus? status, String name = 'ingredient', + ModelStatus status = ModelStatus.normal, this.currentAmount = 0.0, this.totalAmount, this.warningAmount, this.alertAmount, this.lastAmount, this.updatedAt, - }) : super(id, status) { - this.name = name; - } + }) : super(id, name, status); factory Ingredient.fromObject(IngredientObject object) => Ingredient( id: object.id, diff --git a/lib/models/stock/quantity.dart b/lib/models/stock/quantity.dart index f1da3270..0178b5ef 100644 --- a/lib/models/stock/quantity.dart +++ b/lib/models/stock/quantity.dart @@ -14,12 +14,10 @@ class Quantity extends Model Quantity({ String? id, - ModelStatus? status, String name = 'quantity', + ModelStatus status = ModelStatus.normal, this.defaultProportion = 1, - }) : super(id, status) { - this.name = name; - } + }) : super(id, name, status); factory Quantity.fromObject(QuantityObject object) => Quantity( id: object.id, diff --git a/lib/models/stock/replenishment.dart b/lib/models/stock/replenishment.dart index 3c1f04da..cffa90ef 100644 --- a/lib/models/stock/replenishment.dart +++ b/lib/models/stock/replenishment.dart @@ -17,13 +17,11 @@ class Replenishment extends Model Replenishment({ String? id, - ModelStatus? status, String name = 'replenishment', + ModelStatus status = ModelStatus.normal, Map? data, }) : data = data ?? {}, - super(id, status) { - this.name = name; - } + super(id, name, status); factory Replenishment.fromObject(ReplenishmentObject object) => Replenishment( id: object.id, diff --git a/lib/my_app.dart b/lib/my_app.dart index 0ffed2ec..3cd53976 100644 --- a/lib/my_app.dart +++ b/lib/my_app.dart @@ -18,6 +18,7 @@ class MyApp extends StatelessWidget { // singleton be avoid recreate after hot reload. static final router = GoRouter( initialLocation: '/pos', + redirect: (context, state) => state.path == '/' ? Routes.base : null, routes: [Routes.home], // By default, go_router comes with default error screens for both // MaterialApp and CupertinoApp as well as a default error screen in diff --git a/lib/routes.dart b/lib/routes.dart index ce8f625b..559887d7 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:possystem/models/analysis/analysis.dart'; import 'package:possystem/ui/analysis/history_page.dart'; +import 'package:possystem/ui/analysis/widgets/chart_order_modal.dart'; import 'package:possystem/ui/stock/widgets/replenishment_apply.dart'; import 'models/repository/menu.dart'; @@ -116,6 +118,15 @@ class Routes { int.tryParse(state.pathParameters['id'] ?? '0') ?? 0, ), ), + GoRoute( + name: chartOrderModal, + path: 'chart/o/:id/modal', + builder: (ctx, state) { + final id = state.pathParameters['id'] ?? '0'; + final chart = Analysis.instance.getItem(id); + return ChartOrderModal(chart: chart); + }, + ), GoRoute( name: cashierChanger, path: 'cashier/changer', @@ -431,6 +442,7 @@ class Routes { static const order = '/order'; static const orderDetails = '/order/details'; + static const chartOrderModal = '/chart/order/modal'; static const transit = '/transit'; static const transitStation = '/transit/station'; diff --git a/lib/services/storage.dart b/lib/services/storage.dart index 9c853de9..5751c9ae 100644 --- a/lib/services/storage.dart +++ b/lib/services/storage.dart @@ -10,7 +10,7 @@ class Storage { bool _initialized = false; - /// add data into record + /// add data into record, overwrite and create if needed Future add( Stores storeId, String recordId, @@ -158,6 +158,7 @@ enum Stores { quantities, cashier, orderAttributes, + analysis, } class StorageSanitizedValue { diff --git a/lib/ui/analysis/analysis_view.dart b/lib/ui/analysis/analysis_view.dart index b3ff1a5d..b28418c7 100644 --- a/lib/ui/analysis/analysis_view.dart +++ b/lib/ui/analysis/analysis_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:possystem/components/style/head_tail_tile.dart'; +import 'package:go_router/go_router.dart'; import 'package:possystem/components/style/route_circular_button.dart'; import 'package:possystem/components/tutorial.dart'; -import 'package:possystem/helpers/util.dart'; -import 'package:possystem/models/repository/seller.dart'; +import 'package:possystem/constants/icons.dart'; +import 'package:possystem/models/analysis/analysis.dart'; import 'package:possystem/routes.dart'; -import 'package:possystem/settings/currency_setting.dart'; -import 'package:possystem/ui/analysis/widgets/analysis_card.dart'; +import 'package:possystem/ui/analysis/widgets/goals_card_view.dart'; +import 'package:possystem/ui/analysis/widgets/chart_card_view.dart'; class AnalysisView extends StatelessWidget { final TutorialInTab? tab; @@ -17,51 +17,76 @@ class AnalysisView extends StatelessWidget { Widget build(BuildContext context) { return TutorialWrapper( tab: tab, - child: ListView(children: [ - const Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - RouteCircularButton( - key: Key('anal.order'), - icon: Icons.store_sharp, - route: Routes.order, - text: '點餐', - ), - SizedBox.square(dimension: 96.0), - RouteCircularButton( - key: Key('anal.history'), - icon: Icons.calendar_month_sharp, - route: Routes.history, - text: '紀錄', - ), - ]), - const SizedBox(height: 4.0), - metricsCard, - // TODO: 折線圖、圓餅圖 - ]), - ); - } + child: ListenableBuilder( + listenable: Analysis.instance, + builder: (context, child) => ListView.builder( + itemCount: Analysis.instance.length + 6, + itemBuilder: (context, index) { + switch (index) { + case 0: + return child; + case 1: + return const GoalsCardView(); + case 2: + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '圖表分析', + style: Theme.of(context).textTheme.headlineSmall, + ), + ElevatedButton.icon( + key: const Key('anal.add_chart'), + icon: const Icon(KIcons.add), + label: const Text('新增圖表'), + onPressed: () => context.pushNamed( + Routes.chartOrderModal, + pathParameters: { + 'id': '0', + }, + ), + ), + ], + ), + ); + } + + index -= 3; + if (Analysis.instance.length > index) { + return Center( + child: ChartCardView( + chart: Analysis.instance.items.elementAt(index), + ), + ); + } - Widget get metricsCard { - return AnalysisCard( - id: 'summarize', - notifier: Seller.instance, - builder: (context, metric) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('銷售', style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 4.0), - // TODO: add target - HeadTailTile(head: '訂單數', tail: metric.count.toString()), - HeadTailTile(head: '營收', tail: metric.price.toCurrency()), - HeadTailTile(head: '成本', tail: metric.cost.toCurrency()), - HeadTailTile(head: '利潤', tail: metric.revenue.toCurrency()), - ]); - }, - loader: () { - final range = Util.getDateRange(); - return Seller.instance.getMetrics( - range.start, - range.end, - ); - }, + if (index == Analysis.instance.length) { + return const SizedBox(height: 128.0); + } + return null; + }, + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + RouteCircularButton( + key: Key('anal.order'), + icon: Icons.store_sharp, + route: Routes.order, + text: '點餐', + ), + SizedBox.square(dimension: 96.0), + RouteCircularButton( + key: Key('anal.history'), + icon: Icons.calendar_month_sharp, + route: Routes.history, + text: '紀錄', + ), + ], + ), + ), ); } } diff --git a/lib/ui/analysis/widgets/analysis_card.dart b/lib/ui/analysis/widgets/analysis_card.dart deleted file mode 100644 index 2e3b6fee..00000000 --- a/lib/ui/analysis/widgets/analysis_card.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:possystem/components/style/circular_loading.dart'; -import 'package:possystem/helpers/logger.dart'; -import 'package:visibility_detector/visibility_detector.dart'; - -class AnalysisCard extends StatefulWidget { - final Widget Function(BuildContext context, T metric) builder; - - final Future Function() loader; - - final ChangeNotifier? notifier; - - final String id; - - const AnalysisCard({ - Key? key, - required this.id, - required this.builder, - required this.loader, - this.notifier, - }) : super(key: key); - - @override - State> createState() => _AnalysisCardState(); -} - -class _AnalysisCardState extends State> { - T? metric; - String? error; - bool shouldReload = false; - - @override - Widget build(BuildContext context) { - final m = metric; - final e = error; - final child = e != null - ? Center(child: Text(e)) - : m == null - ? const CircularLoading() - : widget.builder(context, m); - - final card = ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: SizedBox( - width: double.infinity, - child: AspectRatio( - aspectRatio: 1.6, - child: Padding( - padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0), - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).cardColor), - borderRadius: BorderRadius.circular(8.0), - ), - child: child, - ), - ), - ), - ), - ); - - if (!shouldReload) { - return card; - } - - return Stack(children: [ - card, - Positioned.fill( - child: VisibilityDetector( - key: Key('anal_card.${widget.id}'), - onVisibilityChanged: (info) async { - if (info.visibleFraction > 0) { - final m = await load(); - setState(() { - metric = m; - shouldReload = false; - }); - } - }, - child: const ColoredBox( - color: Colors.black12, - child: Center( - child: SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ), - ), - ) - ]); - } - - @override - void initState() { - super.initState(); - - load().then((value) => setState(() => metric = value)); - widget.notifier?.addListener(reload); - } - - @override - void dispose() { - super.dispose(); - widget.notifier?.removeListener(reload); - } - - Future load() { - return widget.loader().onError((e, stack) { - Log.err(e ?? 'unknown', 'load_metrics', stack); - setState(() => error = e?.toString() ?? 'unknown'); - return Future.value(); - }); - } - - void reload() { - if (!shouldReload) { - setState(() { - shouldReload = true; - }); - } - } -} diff --git a/lib/ui/analysis/widgets/calendar_view.dart b/lib/ui/analysis/widgets/calendar_view.dart index 3d81f86e..2dfb26db 100644 --- a/lib/ui/analysis/widgets/calendar_view.dart +++ b/lib/ui/analysis/widgets/calendar_view.dart @@ -43,9 +43,8 @@ class _CalendarViewState extends State { @override Widget build(BuildContext context) { - return MediaQuery( - // text being too large will cause overlay - data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + // text being too large will cause overlay + return MediaQuery.withNoTextScaling( child: TableCalendar( firstDay: DateTime(2021, 1), lastDay: DateTime.now(), @@ -161,12 +160,19 @@ class _CalendarViewState extends State { final start = DateTime(local.year, local.month).subtract(const Duration(days: 7)); - final counts = await Seller.instance.getCountPerDay(start, end); + final metrics = await Seller.instance.getMetricsInPeriod( + start, + end, + types: [OrderMetricType.count], + period: MetricsPeriod.day, + ); if (mounted) { setState(() { _loadedMonths.add(_hashMonth(local)); - _loadedCounts.addAll(counts); + _loadedCounts.addAll({ + for (final m in metrics) m.at: m.count, + }); }); } } diff --git a/lib/ui/analysis/widgets/chart_card_view.dart b/lib/ui/analysis/widgets/chart_card_view.dart new file mode 100644 index 00000000..e4e91142 --- /dev/null +++ b/lib/ui/analysis/widgets/chart_card_view.dart @@ -0,0 +1,311 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:possystem/components/bottom_sheet_actions.dart'; +import 'package:possystem/components/style/more_button.dart'; +import 'package:possystem/constants/icons.dart'; +import 'package:possystem/helpers/util.dart'; +import 'package:possystem/models/analysis/analysis.dart'; +import 'package:possystem/models/analysis/chart.dart'; +import 'package:possystem/models/repository/seller.dart'; +import 'package:possystem/routes.dart'; +import 'package:possystem/translator.dart'; +import 'package:possystem/ui/analysis/widgets/reloadable_card.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +class ChartCardView extends StatefulWidget { + final Chart chart; + + const ChartCardView({ + Key? key, + required this.chart, + }) : super(key: key); + + @override + State> createState() => _ChartCardViewState(); +} + +class _ChartCardViewState extends State> { + /// Range of the chart, it can updated by the user + late ValueNotifier range; + + @override + Widget build(BuildContext context) { + return ReloadableCard>( + id: widget.chart.name, + wrappedByCard: false, + notifier: range, + builder: (context, metric) { + return Column(children: [ + Row(children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + widget.chart.name, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + IconButton( + key: Key('chart.${widget.chart.id}.reset'), + onPressed: _resetRange, + icon: const Icon(Icons.refresh_sharp), + ), + MoreButton( + key: Key('chart.${widget.chart.id}.more'), + onPressed: _showActions, + ), + ]), + buildChart(context, metric), + buildRangeSlider(), + ]); + }, + loader: () => widget.chart.loader(range.value.start, range.value.end), + ); + } + + Widget buildChart(BuildContext context, List metrics) { + if (metrics.isEmpty) { + return const SizedBox( + width: 128, + height: 128, + child: Center(child: Text('沒有資料')), + ); + } + + switch (widget.chart.type) { + case AnalysisChartType.cartesian: + return _CartesianChart( + chart: widget.chart as CartesianChart, + metrics: metrics as List, + ); + case AnalysisChartType.circular: + return _CircularChart( + chart: widget.chart as CircularChart, + metrics: metrics as List, + ); + } + } + + Widget buildRangeSlider() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + IconButton( + onPressed: () => _updateRange( + range.value.start.add( + Duration(days: -widget.chart.range.duration.inDays), + ), + ), + icon: const Icon(Icons.arrow_back_ios_new_sharp), + ), + Expanded(child: buildRangeText()), + IconButton( + onPressed: () => _updateRange( + range.value.start.add(widget.chart.range.duration), + ), + icon: const Icon(Icons.arrow_forward_ios_sharp), + ), + ], + ), + ); + } + + Widget buildRangeText() { + final f = DateFormat.MMMd(S.localeName); + return OutlinedButton( + key: Key('chart.${widget.chart.id}.range_button'), + onPressed: () async { + final date = await showDatePicker( + context: context, + firstDate: DateTime(2021), + lastDate: DateTime.now(), + initialDate: range.value.start, + helpText: '選擇日期範圍的開始', + ); + + if (date != null) { + _updateRange(date); + } + }, + child: + Text('${f.format(range.value.start)} – ${f.format(range.value.end)}'), + ); + } + + @override + void initState() { + super.initState(); + + range = ValueNotifier(DateTimeRange( + start: DateTime.now(), + end: DateTime.now(), + )); + _resetRange(); + + widget.chart.addListener(_resetRange); + } + + @override + void dispose() { + widget.chart.removeListener(_resetRange); + range.dispose(); + super.dispose(); + } + + void _resetRange() { + final dur = widget.chart.range.duration; + final newValue = Util.getDateRange( + now: DateTime.now().subtract(dur), + days: widget.chart.withToday ? dur.inDays + 1 : dur.inDays, + ); + + if (range.value != newValue) { + range.value = newValue; + } else { + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member + range.notifyListeners(); + } + } + + void _updateRange(DateTime start) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + if (start == today || start.isAfter(today)) return; + + int days = widget.chart.range.duration.inDays; + if (widget.chart.withToday && + Util.toUTC(now: start) == + Util.toUTC(now: today.subtract(widget.chart.range.duration))) { + days = days + 1; + } + + range.value = Util.getDateRange( + now: start, + days: days, + ); + } + + void _showActions() async { + await BottomSheetActions.withDelete( + context, + deleteCallback: widget.chart.remove, + deleteValue: 0, + warningContent: Text(S.dialogDeletionContent(widget.chart.name, '')), + actions: >[ + BottomSheetAction( + title: const Text('編輯圖表'), + leading: const Icon(KIcons.modal), + route: Routes.chartOrderModal, + routePathParameters: {'id': widget.chart.id}, + ), + ], + ); + } +} + +class _CartesianChart extends StatelessWidget { + final CartesianChart chart; + + final List metrics; + + const _CartesianChart({ + required this.chart, + required this.metrics, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + // catch drag event to prevent the parent from scrolling + onHorizontalDragStart: (details) {}, + child: SfCartesianChart( + plotAreaBorderWidth: 0.7, + selectionType: SelectionType.point, + selectionGesture: ActivationMode.singleTap, + // get the different unit axis + axes: chart.units + .take(2) + .mapIndexed((i, e) => NumericAxis( + opposedPosition: i == 1, + name: e.name, + labelFormat: e.labelFormat, + )) + .toList(), + primaryXAxis: DateTimeAxis( + enableAutoIntervalOnZooming: false, + dateFormat: DateFormat(chart.range.period.format, S.localeName), + majorGridLines: const MajorGridLines(width: 0), + ), + primaryYAxis: const NumericAxis(isVisible: false), + trackballBehavior: TrackballBehavior( + enable: true, + activationMode: ActivationMode.singleTap, + tooltipDisplayMode: TrackballDisplayMode.groupAllPoints, + tooltipSettings: const InteractiveTooltip( + format: 'series.name : point.y', + ), + ), + legend: const Legend( + isVisible: true, + ), + series: chart.keyUnits().map( + (keyUnit) { + return SplineSeries( + markerSettings: const MarkerSettings(isVisible: true), + name: keyUnit.key, + yAxisName: keyUnit.value.name, + xValueMapper: (v, i) => v.at, + yValueMapper: (v, i) => v.value(keyUnit.key), + dataSource: metrics, + ); + }, + ).toList(), + ), + ); + } +} + +class _CircularChart extends StatelessWidget { + final CircularChart chart; + + final List metrics; + + const _CircularChart({ + required this.chart, + required this.metrics, + }); + + @override + Widget build(BuildContext context) { + return SfCircularChart( + tooltipBehavior: TooltipBehavior( + enable: true, + activationMode: ActivationMode.singleTap, + animationDuration: 150, + format: 'point.x : ${chart.units.first.tooltipFormat}', + ), + legend: const Legend( + isVisible: true, + ), + series: [ + PieSeries( + explode: false, // show larger section when tap + name: chart.target.name, + xValueMapper: (v, i) => v.name, + yValueMapper: (v, i) => v.value, + dataSource: metrics, + dataLabelMapper: (v, i) => '${v.percent.prettyString()}%', + dataLabelSettings: const DataLabelSettings( + isVisible: true, + labelPosition: ChartDataLabelPosition.inside, + overflowMode: OverflowMode.shift, + labelIntersectAction: LabelIntersectAction.none, + ), + ), + ], + ); + } +} diff --git a/lib/ui/analysis/widgets/chart_order_modal.dart b/lib/ui/analysis/widgets/chart_order_modal.dart new file mode 100644 index 00000000..b0cae239 --- /dev/null +++ b/lib/ui/analysis/widgets/chart_order_modal.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:possystem/components/mixin/item_modal.dart'; +import 'package:possystem/components/style/text_divider.dart'; +import 'package:possystem/helpers/validator.dart'; +import 'package:possystem/models/analysis/analysis.dart'; +import 'package:possystem/models/analysis/chart.dart'; +import 'package:possystem/models/analysis/chart_object.dart'; +import 'package:possystem/models/repository/seller.dart'; +import 'package:possystem/translator.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +class ChartOrderModal extends StatefulWidget { + final Chart? chart; + + const ChartOrderModal({super.key, required this.chart}); + + @override + State createState() => _ChartOrderModalState(); +} + +class _ChartOrderModalState extends State + with ItemModal { + final _nameController = TextEditingController(); + + AnalysisChartType type = AnalysisChartType.cartesian; + bool withToday = false; + bool ignoreEmpty = true; + OrderChartRange range = OrderChartRange.sevenDays; + late OrderMetricTarget target; + final metrics = []; + final targetItems = []; + + @override + String get title => widget.chart?.name ?? S.analysisChartCreate; + + @override + List buildFormFields() { + return [ + p(TextFormField( + key: const Key('chart.title'), + controller: _nameController, + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: S.analysisChartNameLabel, + filled: false, + ), + maxLength: 30, + validator: Validator.textLimit(S.analysisChartNameLabel, 16), + )), + CheckboxListTile( + key: const Key('chart.withToday'), + controlAffinity: ListTileControlAffinity.leading, + value: withToday, + selected: withToday, + onChanged: (bool? value) { + setState(() { + withToday = value!; + }); + }, + title: Text(S.analysisChartWithTodayLabel), + ), + CheckboxListTile( + key: const Key('chart.ignoreEmpty'), + controlAffinity: ListTileControlAffinity.leading, + value: ignoreEmpty, + selected: ignoreEmpty, + onChanged: (bool? value) { + setState(() { + ignoreEmpty = value!; + }); + }, + title: Text(S.analysisChartIgnoreEmptyLabel), + subtitle: Text(S.analysisChartIgnoreEmptyHelper), + ), + _buildWrappedChoices( + S.analysisChartRangeLabel, + '${S.analysisChartRangeHelper}\n${S.singleChoice}', + OrderChartRange.values.map((e) { + return ChoiceChip( + key: Key('chart.range.${e.name}'), + selected: range == e, + label: Text(S.analysisChartRange(e.name)), + onSelected: (bool value) { + if (value && range != e) { + setState(() { + range = e; + }); + } + }, + ); + }), + ), + TextDivider(label: S.analysisChartTypeLabel), + p(SizedBox( + width: double.infinity, + child: Wrap( + spacing: 4.0, + children: AnalysisChartType.values.map((e) { + return ChoiceChip( + key: Key('chart.type.${e.name}'), + selected: type == e, + label: Text(S.analysisChartType(e.name)), + onSelected: (bool value) { + setState(() { + type = e; + _updateTarget(_allowedTargets.first); + }); + }, + ); + }).toList(), + ), + )), + _buildExampleChart(), + TextDivider(label: S.analysisChartDataPropertiesDivider), + _buildWrappedChoices( + S.analysisChartTargetLabel, + S.analysisChartTargetHelper, + _allowedTargets.map((e) { + return ChoiceChip( + key: Key('chart.target.${e.name}'), + selected: target == e, + label: Text(S.analysisChartTarget(e.name)), + onSelected: (bool value) { + if (value && target != e) { + setState(() { + _updateTarget(e); + }); + } + }, + ); + }), + ), + _buildWrappedChoices( + S.analysisChartMetricLabel, + '${S.analysisChartMetricHelper}\n${_singleMetric ? S.singleChoice : S.multiChoices}', + _allowedMetrics.map((e) { + return ChoiceChip( + key: Key('chart.metrics.${e.name}'), + selected: metrics.contains(e), + label: Text(S.analysisChartMetric(e.name)), + onSelected: (bool value) { + setState(() { + if (value) { + if (_singleMetric) metrics.clear(); + + metrics.add(e); + } else if (metrics.length > 1) { + // Can't let metrics be empty + metrics.remove(e); + } + }); + }, + ); + }), + ), + if (_hasTargetItems) + _buildWrappedChoices( + S.analysisChartTargetItemLabel, + '${S.analysisChartTargetItemHelper}\n${_singleTargetItem ? S.singleChoice : S.multiChoices}', + _buildTargetItems(), + ), + const SizedBox(height: 16), + ]; + } + + Widget _buildWrappedChoices( + String label, + String description, + Iterable chips, + ) { + final textTheme = Theme.of(context).textTheme; + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 12.0), + p(Text(label, style: textTheme.titleMedium)), + p(Text(description, style: textTheme.labelMedium)), + p(SizedBox( + width: double.infinity, + child: Wrap( + spacing: 4.0, + // runSpacing: 4.0, + children: chips.toList(), + ), + )), + ]); + } + + Iterable _buildTargetItems() sync* { + yield ChoiceChip( + key: const Key('chart.item_all'), + selected: targetItems.isEmpty, + label: Text(S.analysisChartTargetItemSelectAll), + onSelected: _singleTargetItem + ? null + : (bool value) { + if (value) { + setState(() { + targetItems.clear(); + }); + } + }, + ); + + yield const SizedBox( + height: 44, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: VerticalDivider(), + ), + ); + + yield* target.getItems().map((e) => ChoiceChip( + key: Key('chart.item.${e.id}'), + selected: targetItems.contains(e.name), + label: Text(e.name), + onSelected: (bool value) { + setState(() { + if (value) { + if (_singleTargetItem) targetItems.clear(); + + targetItems.add(e.name); + } else if (!_singleTargetItem) { + targetItems.remove(e.name); + } + }); + }, + )); + } + + Widget _buildExampleChart() { + switch (type) { + case AnalysisChartType.cartesian: + return SfCartesianChart( + plotAreaBorderWidth: 0.7, + enableAxisAnimation: false, + selectionGesture: ActivationMode.none, + primaryXAxis: const NumericAxis(labelFormat: ' '), + primaryYAxis: const NumericAxis( + minimum: 0, + maximum: 7, + interval: 1, + labelFormat: ' ', + ), + series: [ + SplineSeries( + xValueMapper: (_, i) => i, + yValueMapper: (int data, _) => data, + dataSource: const [3, 1, 4, 6, 5, 2, 5], + ), + ], + ); + case AnalysisChartType.circular: + return SfCircularChart( + title: const ChartTitle(text: '範例'), + selectionGesture: ActivationMode.none, + series: [ + PieSeries( + dataSource: const [12, 34, 54], + xValueMapper: (_, i) => i, + yValueMapper: (int data, _) => data, + ), + ], + ); + } + } + + @override + void initState() { + super.initState(); + + final chart = widget.chart; + if (chart == null) { + target = _allowedTargets.first; + metrics.add(_allowedMetrics.first); + } else { + _nameController.text = chart.name; + type = chart.type; + withToday = chart.withToday; + ignoreEmpty = chart.ignoreEmpty; + range = chart.range; + target = chart.target; + metrics.addAll(chart.metrics); + targetItems.addAll(chart.targetItems); + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Future updateItem() async { + final object = ChartObject( + name: _nameController.text, + range: range, + withToday: withToday, + ignoreEmpty: ignoreEmpty, + target: target, + metrics: metrics, + targetItems: targetItems.toSet().toList(), + ); + final model = type == AnalysisChartType.circular + ? CircularChart.fromObject(object) + : CartesianChart.fromObject(object); + + if (widget.chart == null) { + await Analysis.instance.addItem(model as Chart); + } else { + await widget.chart!.update(model.toObject()); + } + + if (mounted && context.canPop()) { + context.pop(); + } + } + + Iterable get _allowedTargets { + switch (type) { + case AnalysisChartType.circular: + return [ + OrderMetricTarget.catalog, + OrderMetricTarget.product, + OrderMetricTarget.ingredient, + OrderMetricTarget.attribute, + ]; + case AnalysisChartType.cartesian: + return OrderMetricTarget.values; + } + } + + Iterable get _allowedMetrics { + switch (target) { + case OrderMetricTarget.order: + case OrderMetricTarget.catalog: + case OrderMetricTarget.product: + return OrderMetricType.values; + default: + return [ + OrderMetricType.count, + ]; + } + } + + bool get _singleMetric => target != OrderMetricTarget.order; + + bool get _singleTargetItem => + type == AnalysisChartType.circular && + target == OrderMetricTarget.attribute; + + bool get _hasTargetItems => target != OrderMetricTarget.order; + + void _updateTarget(OrderMetricTarget e) { + target = e; + metrics.clear(); + metrics.add(_allowedMetrics.first); + targetItems.clear(); + + if (_singleTargetItem) { + targetItems.add(target.getItems().first.name); + } + } +} diff --git a/lib/ui/analysis/widgets/goals_card_view.dart b/lib/ui/analysis/widgets/goals_card_view.dart new file mode 100644 index 00000000..a33faa73 --- /dev/null +++ b/lib/ui/analysis/widgets/goals_card_view.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:possystem/components/style/info_popup.dart'; +import 'package:possystem/helpers/analysis/ema_calculator.dart'; +import 'package:possystem/helpers/util.dart'; +import 'package:possystem/models/repository/seller.dart'; +import 'package:possystem/ui/analysis/widgets/reloadable_card.dart'; + +class GoalsCardView extends StatefulWidget { + /// Help to calculate the EMA of the last 20 days. + final EMACalculator calculator; + + const GoalsCardView({ + super.key, + this.calculator = const EMACalculator(20), + }); + + @override + State createState() => _GoalsCardViewState(); +} + +class _GoalsCardViewState extends State { + OrderDataPerDay? goal; + + @override + Widget build(BuildContext context) { + return ReloadableCard( + id: 'goals', + title: '目標', + notifier: Seller.instance, + builder: _builder, + loader: _loader, + ); + } + + Widget _builder(BuildContext context, OrderDataPerDay metric) { + final style = Theme.of(context).textTheme.bodyLarge; + final goals = [ + _GoalItem( + type: OrderMetricType.count, + current: metric.count, + goal: goal!.count, + style: style, + name: '訂單數', + desc: + '訂單數反映了產品對顧客的吸引力。它代表了市場對你產品的需求程度,能幫助你了解何種產品或時段最受歡迎。高訂單數可能意味著你的定價策略或行銷活動取得成功,是商業模型有效性的指標之一。但要注意,單純追求高訂單數可能會忽略盈利能力。', + ), + _GoalItem( + type: OrderMetricType.price, + current: metric.price, + goal: goal!.price, + style: style, + name: '營收', + desc: + '營收代表總銷售額,是業務規模的指標。高營收可能顯示了你的產品受歡迎且銷售良好,但營收無法反映出業務的可持續性和盈利能力。它不考慮成本和利潤,因此單純追求高營收可能會忽視實際利潤狀況。', + ), + _GoalItem( + type: OrderMetricType.revenue, + current: metric.revenue, + goal: goal!.revenue, + style: style, + name: '盈利', + desc: + '盈利是店家能否持續經營的關鍵。盈利直接反映了營運效率和成本管理能力。不同於營收,盈利考慮了生意的開支,包括原料成本、人力、租金等,這是一個更實際的指標,能幫助你評估經營是否有效且可持續。即使有高營收,但如果成本高於營收,最終可能面臨經營困境。', + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('成本', style: style), + Text(metric.cost.prettyString(), style: style), + ], + ), + ]; + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: goals, + ), + ), + if (goal!.revenue != 0) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Stack(children: [ + AspectRatio( + aspectRatio: 1.0, + child: CircularProgressIndicator( + value: metric.revenue / goal!.revenue, + color: Colors.pink, + backgroundColor: Colors.grey.withOpacity(0.2), + strokeWidth: 20, + ), + ), + Positioned.fill( + child: Center( + child: Text( + '利潤達成\n${(metric.revenue / goal!.revenue * 100).prettyString()}%', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ]), + ), + ), + ], + ); + } + + Future _loader() async { + final range = Util.getDateRange(); + final result = await Seller.instance.getMetricsInPeriod( + // If there is no data, we will calculate the EMA of the last 20 days. + goal == null + ? range.start.subtract(Duration(days: widget.calculator.length)) + : range.start, + range.end, + types: [ + OrderMetricType.count, + OrderMetricType.price, + OrderMetricType.revenue, + OrderMetricType.cost, + ], + period: MetricsPeriod.day, + ignoreEmpty: false, + ); + + // Remove the last data, which is the today's data. + final last = result.removeLast(); + + goal ??= OrderDataPerDay( + at: range.end, // this is dummy data, we don't need the date. + values: { + 'count': + widget.calculator.calculate(result.map((e) => e.count)).toInt(), + 'price': widget.calculator.calculate(result.map((e) => e.price)), + 'revenue': widget.calculator.calculate(result.map((e) => e.revenue)), + }, + ); + + return last; + } +} + +class _GoalItem extends StatelessWidget { + final OrderMetricType type; + + final String name; + + final String desc; + + final num current; + + final num goal; + + final TextStyle? style; + + const _GoalItem({ + required this.type, + required this.name, + required this.desc, + required this.current, + required this.goal, + this.style, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Text(name, style: style), + const SizedBox(width: 4.0), + InfoPopup(desc), + ]), + RichText( + text: TextSpan( + text: current.prettyString(), + style: style, + children: [ + if (goal != 0) + TextSpan( + text: '/${goal.prettyString()}', + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ), + const SizedBox(height: 4), + ], + ); + } +} diff --git a/lib/ui/analysis/widgets/reloadable_card.dart b/lib/ui/analysis/widgets/reloadable_card.dart new file mode 100644 index 00000000..53bc956c --- /dev/null +++ b/lib/ui/analysis/widgets/reloadable_card.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:possystem/components/style/circular_loading.dart'; +import 'package:possystem/helpers/logger.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +class ReloadableCard extends StatefulWidget { + final Widget Function(BuildContext context, T metric) builder; + + final Future Function() loader; + + final ChangeNotifier? notifier; + + /// Required if you want to reload the card when it's visible. + final String? id; + + final String? title; + + final bool wrappedByCard; + + const ReloadableCard({ + Key? key, + this.id, + required this.builder, + required this.loader, + this.title, + this.notifier, + this.wrappedByCard = true, + }) : super(key: key); + + @override + State> createState() => _ReloadableCardState(); +} + +class _ReloadableCardState extends State> { + /// Error message when loading failed + String? error; + + /// Data loaded from loader + T? data; + + /// Whether the card is reloading + bool reloadable = false; + + /// Last built target, used to prevent rebuild when reloading + Widget? lastBuiltTarget; + + @override + Widget build(BuildContext context) { + return Stack(children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 432), + child: SizedBox( + width: double.infinity, + child: buildWrapper(buildTarget()), + ), + ), + if (reloadable) buildReloading(), + ]); + } + + /// Main content of the card + Widget buildTarget() { + if (error != null) { + return Center(child: Text(error!)); + } + + if (data == null) { + return const CircularLoading(); + } + + // when reloading, only show the circular loading indicator and + // should not rebuild the target + if (reloadable && lastBuiltTarget != null) { + return lastBuiltTarget!; + } + + return lastBuiltTarget = widget.builder(context, data as T); + } + + /// Wrap the target with card or not + Widget buildWrapper(Widget child) { + if (widget.wrappedByCard) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.title != null) buildTitle(), + Card( + margin: const EdgeInsets.only(top: 8.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: child, + ), + ), + ], + ), + ); + } + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 8.0), + child, + ]); + } + + Widget buildTitle() { + return Text( + widget.title!, + style: Theme.of(context).textTheme.headlineSmall, + ); + } + + Widget buildReloading() { + return Positioned.fill( + child: VisibilityDetector( + key: Key('anal_card.${widget.id}'), + onVisibilityChanged: (info) async { + // if partially visible + if (info.visibleFraction > 0) { + await reload(); + } + }, + child: const ColoredBox( + color: Colors.black12, + child: Center( + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ), + ); + } + + @override + void initState() { + super.initState(); + + load().then((value) => setState(() => data = value)); + widget.notifier?.addListener(changeListener); + } + + @override + void dispose() { + super.dispose(); + widget.notifier?.removeListener(changeListener); + } + + Future load() { + return widget.loader().onError((e, stack) { + Log.err(e ?? 'unknown', 'load_metrics', stack); + setState(() => error = e?.toString() ?? 'unknown'); + return Future.value(null); + }); + } + + Future reload() async { + // only reload when data changed + if (reloadable) { + reloadable = false; + lastBuiltTarget = null; + final inline = await load(); + + setState(() { + data = inline; + }); + } + } + + void changeListener() { + if (!reloadable) { + setState(() { + reloadable = true; + }); + } + } +} diff --git a/lib/ui/home/home_page.dart b/lib/ui/home/home_page.dart index 0397bd35..dbb3185d 100644 --- a/lib/ui/home/home_page.dart +++ b/lib/ui/home/home_page.dart @@ -96,6 +96,12 @@ class _HomePageState extends State vsync: this, ); } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } } class _CustomTab extends StatelessWidget { @@ -109,14 +115,15 @@ class _CustomTab extends StatelessWidget { @override Widget build(BuildContext context) { // fix the font size, avoid scaling - return Tab( - iconMargin: const EdgeInsets.only(bottom: 6), - child: Text( - text, - style: const TextStyle(fontSize: 14), - textScaleFactor: 1, - softWrap: false, - overflow: TextOverflow.fade, + return MediaQuery.withNoTextScaling( + child: Tab( + iconMargin: const EdgeInsets.only(bottom: 6), + child: Text( + text, + style: const TextStyle(fontSize: 14), + softWrap: false, + overflow: TextOverflow.fade, + ), ), ); } diff --git a/lib/ui/image_gallery_page.dart b/lib/ui/image_gallery_page.dart index 4502f4c4..44cc014b 100644 --- a/lib/ui/image_gallery_page.dart +++ b/lib/ui/image_gallery_page.dart @@ -29,8 +29,9 @@ class ImageGalleryPageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: safelyPop, + return PopScope( + canPop: !isSelecting, + onPopInvoked: onPopInvoked, child: SafeArea( child: isSelecting ? buildSelectingScaffold() : buildScaffold(), ), @@ -48,7 +49,7 @@ class ImageGalleryPageState extends State { appBar: AppBar( leading: CloseButton( key: const Key('image_gallery.cancel'), - onPressed: safelyPop, + onPressed: () => onPopInvoked(false), ), actions: [ TextButton( @@ -218,11 +219,11 @@ class ImageGalleryPageState extends State { try { await Future.wait(target.map((image) => image.file.delete())); - if (context.mounted) { + if (mounted) { showSnackBar(context, S.actSuccess); } } catch (e) { - if (context.mounted) { + if (mounted) { showSnackBar(context, '有一個或多個圖片沒有刪成功。'); } Log.out(e.toString(), 'delete_image_error'); @@ -231,12 +232,10 @@ class ImageGalleryPageState extends State { } } - Future safelyPop() async { - if (isSelecting) { + void onPopInvoked(bool didPop) { + if (!didPop) { cancelSelecting(); - return false; } - return true; } void cancelSelecting({reloadImages = false}) { diff --git a/lib/ui/menu/menu_page.dart b/lib/ui/menu/menu_page.dart index 54506aeb..c11e6012 100644 --- a/lib/ui/menu/menu_page.dart +++ b/lib/ui/menu/menu_page.dart @@ -35,14 +35,15 @@ class _MenuPageState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: _willPop, + return PopScope( + canPop: selected == null, + onPopInvoked: _onPopInvoked, child: Scaffold( appBar: AppBar( title: Text(selected?.name ?? S.menuTitle), leading: PopButton( - onPressed: () async { - if (await _willPop()) { + onPressed: () { + if (_onPopInvoked(selected == null)) { if (context.mounted && context.canPop()) { context.pop(); } @@ -196,14 +197,13 @@ class _MenuPageState extends State { } } - Future _willPop() async { - // if has no clients, it means menu is empty(build without PageView) - if (!controller.hasClients || controller.page == 0) { - return true; + bool _onPopInvoked(bool didPop) { + if (!didPop) { + _goTo(0).then((_) => setState(() => selected = null)); + return false; } - _goTo(0).then((_) => setState(() => selected = null)); - return false; + return true; } Future _goTo(int index) { diff --git a/lib/ui/menu/product_page.dart b/lib/ui/menu/product_page.dart index d78356a9..c3ec7f6e 100644 --- a/lib/ui/menu/product_page.dart +++ b/lib/ui/menu/product_page.dart @@ -142,7 +142,7 @@ class _ProductPageState extends State { ], ); - if (result == _Action.changeImage && context.mounted) { + if (result == _Action.changeImage && mounted) { await widget.product.pickImage(context); } } diff --git a/lib/ui/menu/widgets/catalog_modal.dart b/lib/ui/menu/widgets/catalog_modal.dart index e49efcc6..bdb65995 100644 --- a/lib/ui/menu/widgets/catalog_modal.dart +++ b/lib/ui/menu/widgets/catalog_modal.dart @@ -38,7 +38,7 @@ class _CatalogModalState extends State path: _image, onSelected: (image) => setState(() => _image = image), ), - TextFormField( + p(TextFormField( key: const Key('catalog.name'), controller: _nameController, focusNode: _nameFocusNode, @@ -49,7 +49,7 @@ class _CatalogModalState extends State hintText: S.menuCatalogNameHint, filled: false, ), - onFieldSubmitted: (_) => handleSubmit(), + onFieldSubmitted: handleFieldSubmit, maxLength: 30, validator: Validator.textLimit( S.menuCatalogNameLabel, @@ -61,7 +61,7 @@ class _CatalogModalState extends State : null; }, ), - ), + )), ]; } diff --git a/lib/ui/menu/widgets/product_ingredient_modal.dart b/lib/ui/menu/widgets/product_ingredient_modal.dart index 027ac55f..ce50a3bc 100644 --- a/lib/ui/menu/widgets/product_ingredient_modal.dart +++ b/lib/ui/menu/widgets/product_ingredient_modal.dart @@ -44,7 +44,9 @@ class _ProductIngredientModalState extends State @override List buildFormFields() { return [ - SearchBarWrapper( + // avoid search-bar's label overflow + const SizedBox(height: 12.0), + p(SearchBarWrapper( key: const Key('product_ingredient.search'), text: ingredientName, labelText: S.menuIngredientSearchLabel, @@ -56,12 +58,12 @@ class _ProductIngredientModalState extends State search: (text) async => Stock.instance.sortBySimilarity(text), itemBuilder: _searchItemBuilder, emptyBuilder: _searchEmptyBuilder, - ), - TextFormField( + )), + p(TextFormField( key: const Key('product_ingredient.amount'), controller: _amountController, textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => handleSubmit(), + onFieldSubmitted: handleFieldSubmit, keyboardType: TextInputType.number, focusNode: _amountFocusNode, decoration: InputDecoration( @@ -74,7 +76,7 @@ class _ProductIngredientModalState extends State S.menuIngredientAmountLabel, focusNode: _amountFocusNode, ), - ), + )), ]; } diff --git a/lib/ui/menu/widgets/product_modal.dart b/lib/ui/menu/widgets/product_modal.dart index db5bd4da..56a6b174 100644 --- a/lib/ui/menu/widgets/product_modal.dart +++ b/lib/ui/menu/widgets/product_modal.dart @@ -47,7 +47,7 @@ class _ProductModalState extends State path: _image, onSelected: (image) => setState(() => _image = image), ), - TextFormField( + p(TextFormField( key: const Key('product.name'), controller: _nameController, textInputAction: TextInputAction.next, @@ -70,8 +70,8 @@ class _ProductModalState extends State : null; }, ), - ), - TextFormField( + )), + p(TextFormField( key: const Key('product.price'), controller: _priceController, textInputAction: TextInputAction.next, @@ -86,8 +86,8 @@ class _ProductModalState extends State S.menuProductPriceLabel, focusNode: _priceFocusNode, ), - ), - TextFormField( + )), + p(TextFormField( key: const Key('product.cost'), controller: _costController, textInputAction: TextInputAction.done, @@ -98,12 +98,12 @@ class _ProductModalState extends State hintText: S.menuProductCostHint, filled: false, ), - onFieldSubmitted: (_) => handleSubmit(), + onFieldSubmitted: handleFieldSubmit, validator: Validator.positiveNumber( S.menuProductCostLabel, focusNode: _costFocusNode, ), - ), + )), ]; } diff --git a/lib/ui/menu/widgets/product_quantity_modal.dart b/lib/ui/menu/widgets/product_quantity_modal.dart index f048d5bb..c66f7283 100644 --- a/lib/ui/menu/widgets/product_quantity_modal.dart +++ b/lib/ui/menu/widgets/product_quantity_modal.dart @@ -48,7 +48,9 @@ class _ProductQuantityModalState extends State @override List buildFormFields() { return [ - SearchBarWrapper( + // avoid search-bar's label overflow + const SizedBox(height: 12.0), + p(SearchBarWrapper( key: const Key('product_quantity.search'), text: quantityName, labelText: S.menuQuantitySearchLabel, @@ -59,8 +61,8 @@ class _ProductQuantityModalState extends State search: (text) async => Quantities.instance.sortBySimilarity(text), itemBuilder: _searchItemBuilder, emptyBuilder: _searchEmptyBuilder, - ), - TextFormField( + )), + p(TextFormField( key: const Key('product_quantity.amount'), controller: _amountController, keyboardType: TextInputType.number, @@ -74,8 +76,8 @@ class _ProductQuantityModalState extends State S.menuQuantityAmountLabel, focusNode: _amountFocusNode, ), - ), - TextFormField( + )), + p(TextFormField( key: const Key('product_quantity.price'), controller: _priceController, keyboardType: TextInputType.number, @@ -92,14 +94,14 @@ class _ProductQuantityModalState extends State S.menuQuantityAdditionalPriceLabel, focusNode: _priceFocusNode, ), - ), - TextFormField( + )), + p(TextFormField( key: const Key('product_quantity.cost'), controller: _costController, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, focusNode: _costFocusNode, - onFieldSubmitted: (_) => handleSubmit(), + onFieldSubmitted: handleFieldSubmit, decoration: InputDecoration( prefixIcon: const Icon(Icons.attach_money_sharp), labelText: S.menuQuantityAdditionalCostLabel, @@ -111,7 +113,7 @@ class _ProductQuantityModalState extends State S.menuQuantityAdditionalCostLabel, focusNode: _costFocusNode, ), - ) + )), ]; } diff --git a/lib/ui/order/cashier/order_details_page.dart b/lib/ui/order/cashier/order_details_page.dart index e0e4db09..b7074c08 100644 --- a/lib/ui/order/cashier/order_details_page.dart +++ b/lib/ui/order/cashier/order_details_page.dart @@ -135,7 +135,7 @@ class _OrderDetailsPageState extends State Future _stash() async { final ok = await Cart.instance.stash(); - if (context.mounted && ok && context.canPop()) { + if (mounted && ok && context.canPop()) { context.pop(CheckoutStatus.stash); } } @@ -178,7 +178,7 @@ class _OrderDetailsPageState extends State final status = await Cart.instance.checkout(price.value, paid.value); // send success message - if (context.mounted) { + if (mounted) { if (status == CheckoutStatus.paidNotEnough) { showSnackBar(context, S.orderCashierPaidFailed); } else if (context.canPop()) { diff --git a/lib/ui/order_attr/widgets/order_attribute_modal.dart b/lib/ui/order_attr/widgets/order_attribute_modal.dart index 362ace42..a8561b50 100644 --- a/lib/ui/order_attr/widgets/order_attribute_modal.dart +++ b/lib/ui/order_attr/widgets/order_attribute_modal.dart @@ -34,7 +34,7 @@ class _OrderAttributeModalState extends State @override List buildFormFields() { return [ - TextFormField( + p(TextFormField( key: const Key('order_attribute.name'), controller: _nameController, textInputAction: TextInputAction.send, @@ -44,7 +44,7 @@ class _OrderAttributeModalState extends State hintText: S.orderAttributeNameHint, filled: false, ), - onFieldSubmitted: (_) => handleSubmit(), + onFieldSubmitted: handleFieldSubmit, maxLength: 30, validator: Validator.textLimit( S.orderAttributeNameLabel, @@ -57,7 +57,7 @@ class _OrderAttributeModalState extends State : null; }, ), - ), + )), TextDivider(label: S.orderAttributeModeTitle), ChoiceChipWithHelp( key: modeSelector, diff --git a/lib/ui/order_attr/widgets/order_attribute_option_modal.dart b/lib/ui/order_attr/widgets/order_attribute_option_modal.dart index 73f2bc8e..60b39ddb 100644 --- a/lib/ui/order_attr/widgets/order_attribute_option_modal.dart +++ b/lib/ui/order_attr/widgets/order_attribute_option_modal.dart @@ -61,7 +61,7 @@ class _OrderAttributeModalState extends State ); return [ - TextFormField( + p(TextFormField( key: const Key('order_attribute_option.name'), controller: _nameController, textInputAction: widget.attribute.shouldHaveModeValue @@ -85,7 +85,7 @@ class _OrderAttributeModalState extends State : null; }, ), - ), + )), CheckboxListTile( key: const Key('order_attribute_option.isDefault'), controlAffinity: ListTileControlAffinity.leading, @@ -94,7 +94,8 @@ class _OrderAttributeModalState extends State onChanged: _toggledDefault, title: Text(S.orderAttributeOptionSetToDefault), ), - widget.attribute.shouldHaveModeValue + const SizedBox(height: 12.0), + p(widget.attribute.shouldHaveModeValue ? TextFormField( key: const Key('order_attribute_option.modeValue'), controller: _valueController, @@ -107,10 +108,10 @@ class _OrderAttributeModalState extends State hintText: hint, filled: false, ), - onFieldSubmitted: (_) => handleSubmit(), + onFieldSubmitted: handleFieldSubmit, validator: validator, ) - : HintText(helper), + : Center(child: HintText(helper))), ]; } diff --git a/lib/ui/stock/replenishment_page.dart b/lib/ui/stock/replenishment_page.dart index 96fb4668..a42e17d2 100644 --- a/lib/ui/stock/replenishment_page.dart +++ b/lib/ui/stock/replenishment_page.dart @@ -105,7 +105,7 @@ class _ReplenishmentPageState extends State { pathParameters: {'id': item.id}, ); - if (context.mounted && confirmed == true && context.canPop()) { + if (mounted && confirmed == true && context.canPop()) { context.pop(true); } } diff --git a/lib/ui/stock/widgets/replenishment_modal.dart b/lib/ui/stock/widgets/replenishment_modal.dart index 164ae0ce..e89be751 100644 --- a/lib/ui/stock/widgets/replenishment_modal.dart +++ b/lib/ui/stock/widgets/replenishment_modal.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:possystem/components/mixin/item_modal.dart'; -import 'package:possystem/components/style/hint_text.dart'; +import 'package:possystem/components/style/text_divider.dart'; import 'package:possystem/helpers/validator.dart'; import 'package:possystem/models/objects/stock_object.dart'; import 'package:possystem/models/repository/replenisher.dart'; @@ -39,7 +39,7 @@ class _ReplenishmentModalState extends State final textTheme = Theme.of(context).textTheme; return [ - TextFormField( + p(TextFormField( key: const Key('replenishment.name'), controller: _nameController, textInputAction: TextInputAction.done, @@ -63,8 +63,8 @@ class _ReplenishmentModalState extends State : null; }, ), - ), - HintText(S.stockReplenishmentIngredientListTitle), + )), + TextDivider(label: S.stockReplenishmentIngredientListTitle), for (final ing in Stock.instance.itemList) _buildIngredientField(ing), ]; } @@ -104,7 +104,7 @@ class _ReplenishmentModalState extends State } Widget _buildIngredientField(Ingredient ingredient) { - return TextFormField( + return p(TextFormField( key: Key('replenishment.ingredients.${ingredient.id}'), onSaved: (String? value) { final numValue = num.tryParse(value!); @@ -119,7 +119,7 @@ class _ReplenishmentModalState extends State labelText: ingredient.name, hintText: S.stockReplenishmentIngredientAmountHint, ), - ); + )); } ReplenishmentObject _parseObject() { diff --git a/lib/ui/stock/widgets/stock_ingredient_modal.dart b/lib/ui/stock/widgets/stock_ingredient_modal.dart index 2d0013de..c684898b 100644 --- a/lib/ui/stock/widgets/stock_ingredient_modal.dart +++ b/lib/ui/stock/widgets/stock_ingredient_modal.dart @@ -38,19 +38,24 @@ class _StockIngredientModalState extends State String get title => widget.ingredient?.name ?? S.stockIngredientCreate; @override - Widget buildBody() { + Widget buildForm() { final ingredients = widget.isNew ? const [] : Menu.instance.getIngredients(widget.ingredient!.id); - // 1 for body, 2 for divider and text - final length = ingredients.length + 1 + (widget.isNew ? 0 : 1); + // +2: 1 for form, 2 for text-divider + final length = widget.isNew ? 1 : ingredients.length + 2; return ListView.builder( itemCount: length, itemBuilder: (context, index) { switch (index) { case 0: - return super.buildBody(); + return Form( + key: formKey, + child: Column( + children: buildFormFields(), + ), + ); case 1: return TextDivider( label: S.stockIngredientConnectedProductsCount( @@ -73,7 +78,7 @@ class _StockIngredientModalState extends State @override List buildFormFields() => [ - TextFormField( + p(TextFormField( key: const Key('stock.ingredient.name'), controller: _nameController, textInputAction: TextInputAction.done, @@ -96,8 +101,8 @@ class _StockIngredientModalState extends State : null; }, ), - ), - TextFormField( + )), + p(TextFormField( key: const Key('stock.ingredient.amount'), controller: _amountController, textInputAction: TextInputAction.done, @@ -112,8 +117,8 @@ class _StockIngredientModalState extends State allowNull: true, focusNode: _amountFocusNode, ), - ), - TextFormField( + )), + p(TextFormField( key: const Key('stock.ingredient.totalAmount'), controller: _totalAmountController, textInputAction: TextInputAction.done, @@ -130,7 +135,7 @@ class _StockIngredientModalState extends State allowNull: true, focusNode: _totalAmountFocusNode, ), - ), + )), ]; @override diff --git a/lib/ui/stock/widgets/stock_quantity_modal.dart b/lib/ui/stock/widgets/stock_quantity_modal.dart index 92be297d..d14a90d0 100644 --- a/lib/ui/stock/widgets/stock_quantity_modal.dart +++ b/lib/ui/stock/widgets/stock_quantity_modal.dart @@ -33,7 +33,7 @@ class _StockQuantityModalState extends State @override List buildFormFields() { return [ - TextFormField( + p(TextFormField( key: const Key('quantity.name'), controller: _nameController, textCapitalization: TextCapitalization.words, @@ -56,14 +56,14 @@ class _StockQuantityModalState extends State : null; }, ), - ), - TextFormField( + )), + p(TextFormField( key: const Key('quantity.proportion'), controller: _proportionController, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, focusNode: _proportionFocusNode, - onFieldSubmitted: (_) => handleSubmit(), + onFieldSubmitted: handleFieldSubmit, decoration: InputDecoration( labelText: S.quantityProportionLabel, helperText: S.quantityProportionHelper, @@ -77,7 +77,7 @@ class _StockQuantityModalState extends State allowNull: true, focusNode: _proportionFocusNode, ), - ) + )), ]; } diff --git a/lib/ui/transit/google_sheet/order_setting_page.dart b/lib/ui/transit/google_sheet/order_setting_page.dart index 75071b75..8eeb1209 100644 --- a/lib/ui/transit/google_sheet/order_setting_page.dart +++ b/lib/ui/transit/google_sheet/order_setting_page.dart @@ -37,11 +37,6 @@ class _OrderSettingPageState extends State @override String get title => '訂單匯出設定'; - @override - Widget buildBody() { - return SingleChildScrollView(child: buildForm(buildFormFields())); - } - @override List buildFormFields() { return [ @@ -72,20 +67,18 @@ class _OrderSettingPageState extends State }, ), if (!isOverwrite && withPrefix) - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - '不覆寫而改用附加的時候,建議表單名稱「不要」加上日期前綴', - style: TextStyle(color: Theme.of(context).colorScheme.error), + p( + Center( + child: Text( + '不覆寫而改用附加的時候,建議表單名稱「不要」加上日期前綴', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), ), ), const TextDivider(label: '表單名稱'), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: CardInfoText( - child: Text('拆分表單可以讓你更彈性的去分析資料,\n例如可以到訂單成份細項查詢:今天某個成分總共用了多少。'), - ), - ), + p(const CardInfoText( + child: Text('拆分表單可以讓你更彈性的去分析資料,\n例如可以到訂單成份細項查詢:今天某個成分總共用了多少。'), + )), for (final namer in namers) SheetNamer(prop: namer), ]; } @@ -106,7 +99,7 @@ class _OrderSettingPageState extends State ); await properties.cache(); - if (context.mounted) { + if (mounted) { Navigator.of(context).pop(properties); } } diff --git a/pubspec.lock b/pubspec.lock index f02b8337..92becd7a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -725,6 +725,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + url: "https://pub.dev" + source: hosted + version: "2.0.1" lints: dependency: transitive description: @@ -745,26 +769,26 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.11.0" mime: dependency: transitive description: @@ -825,10 +849,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_parsing: dependency: transitive description: @@ -1166,18 +1190,18 @@ packages: dependency: "direct main" description: name: syncfusion_flutter_charts - sha256: "1f5434cb0dd0118a8f3281a0351559772e09066c2adfd69b16098f38f2b909e9" + sha256: "5940fab3d9901218751b01768d206ccd6c1cacd90bda659712d260ddfabdc87e" url: "https://pub.dev" source: hosted - version: "24.1.44+1" + version: "24.2.8" syncfusion_flutter_core: dependency: transitive description: name: syncfusion_flutter_core - sha256: "083d4a0ddb25435d267328a30c3938d09599f7578f5f0d28fbd9fd5424c1cd51" + sha256: "0db4e83b4ceef9a3c97ba5cfe087d364b5278c8154b859be8accedc1d31fc195" url: "https://pub.dev" source: hosted - version: "24.1.44" + version: "24.2.8" synchronized: dependency: transitive description: @@ -1346,6 +1370,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.0+2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + url: "https://pub.dev" + source: hosted + version: "13.0.0" wakelock: dependency: "direct main" description: @@ -1399,14 +1431,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - web: - dependency: transitive - description: - name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 - url: "https://pub.dev" - source: hosted - version: "0.3.0" web_socket_channel: dependency: transitive description: @@ -1448,5 +1472,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" + dart: ">=3.2.0-0 <4.0.0" flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 358a5450..eca27098 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,7 @@ dependencies: # components table_calendar: ^3.0.9 # 23, 01-06 - syncfusion_flutter_charts: ^24.1.44+1 + syncfusion_flutter_charts: ^24.2.8 spotlight_ant: ^1.0.5 # image diff --git a/test/components/analysis_card_test.dart b/test/components/analysis_card_test.dart index 67c7aa3d..38d1d615 100644 --- a/test/components/analysis_card_test.dart +++ b/test/components/analysis_card_test.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:possystem/ui/analysis/widgets/analysis_card.dart'; +import 'package:possystem/ui/analysis/widgets/reloadable_card.dart'; import 'package:visibility_detector/visibility_detector.dart'; void main() { group('Component AnalysisCard', () { testWidgets('show error', (tester) async { await tester.pumpWidget(MaterialApp( - home: AnalysisCard( + home: ReloadableCard( id: 'test', builder: (context, metric) => const SizedBox.shrink(), loader: () => Future.error('test'), @@ -25,7 +25,7 @@ void main() { VisibilityDetectorController.instance.updateInterval = Duration.zero; await tester.pumpWidget(MaterialApp( - home: AnalysisCard( + home: ReloadableCard( id: 'test', notifier: notifier, builder: (context, metric) => TextButton( diff --git a/test/components/scrollable_draggable_sheet_test.dart b/test/components/scrollable_draggable_sheet_test.dart index 6ecd17a9..e1770fff 100644 --- a/test/components/scrollable_draggable_sheet_test.dart +++ b/test/components/scrollable_draggable_sheet_test.dart @@ -39,10 +39,11 @@ void main() { await tester.tap(find.text('Go')); await tester.pumpAndSettle(); - expect(find.text('title'), findsOneWidget); + expect(tester.getCenter(find.text('title')).dy, equals(588)); - await tester.drag(find.byKey(const Key('t')), const Offset(0, -400)); + await tester.drag(find.byKey(const Key('t')), const Offset(0, -300)); await tester.pumpAndSettle(); + expect(tester.getCenter(find.text('title')).dy, equals(48)); // pop final dynamic widgetsAppState = tester.state(find.byType(WidgetsApp)); @@ -50,7 +51,7 @@ void main() { await tester.pump(); // only reset the sheet - expect(find.text('title'), findsOneWidget); + expect(tester.getCenter(find.text('title')).dy, equals(588)); }); testWidgets('quickly drag without over half', (tester) async { diff --git a/test/components/snackbar_test.dart b/test/components/snackbar_test.dart index 563361c1..01a50371 100644 --- a/test/components/snackbar_test.dart +++ b/test/components/snackbar_test.dart @@ -10,8 +10,12 @@ void main() { body: Builder(builder: (context) { return TextButton( onPressed: () { - showMoreInfoSnackBar(context, 'message', const Text('info'), - label: 'test'); + showMoreInfoSnackBar( + context, + 'message', + const Text('info'), + label: 'label', + ); }, child: const Text('btn')); }), @@ -20,11 +24,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('btn')); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('message'), findsOneWidget); - await tester.tap(find.text('test')); + await tester.tap(find.text('label')); await tester.pumpAndSettle(); expect(find.text('info'), findsOneWidget); diff --git a/test/models/initialize_test.dart b/test/models/initialize_test.dart index bea5af36..37f35f24 100644 --- a/test/models/initialize_test.dart +++ b/test/models/initialize_test.dart @@ -1,9 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:possystem/models/analysis/analysis.dart'; +import 'package:possystem/models/analysis/chart.dart'; import 'package:possystem/models/repository/menu.dart'; import 'package:possystem/models/repository/order_attributes.dart'; import 'package:possystem/models/repository/quantities.dart'; import 'package:possystem/models/repository/replenisher.dart'; +import 'package:possystem/models/repository/seller.dart'; import 'package:possystem/models/repository/stock.dart'; import 'package:possystem/models/stock/ingredient.dart'; import 'package:possystem/models/stock/quantity.dart'; @@ -322,6 +325,37 @@ void main() { expect(c1.itemList.last.modeValue, isNull); }); + test('Analysis', () async { + when(storage.get(Stores.analysis, argThat(isNull))).thenAnswer( + (_) => Future.value({ + 'c-1': { + 'name': 'c-1', + 'type': 0, + 'metrics': [1, 2], + }, + 'c-2': { + 'name': 'c-2', + 'type': 1, + 'metrics': [1], + }, + }), + ); + + await Analysis().initialize(); + + final c1 = Analysis.instance.getItem('c-1')!; + final c2 = Analysis.instance.getItem('c-2')!; + expect(c1.name, equals('c-1')); + expect(c1 is CartesianChart, isTrue); + expect( + c1.metrics, + equals([OrderMetricType.cost, OrderMetricType.revenue]), + ); + expect(c2.name, equals('c-2')); + expect(c2 is CircularChart, isTrue); + expect(c2.metrics, equals([OrderMetricType.cost])); + }); + setUpAll(() { initializeDatabase(); initializeStorage(); diff --git a/test/ui/analysis/analysis_view_test.dart b/test/ui/analysis/analysis_view_test.dart index a9aee7ab..c17d5ddc 100644 --- a/test/ui/analysis/analysis_view_test.dart +++ b/test/ui/analysis/analysis_view_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mockito/mockito.dart'; +import 'package:possystem/models/analysis/analysis.dart'; +import 'package:possystem/models/analysis/chart.dart'; import 'package:possystem/models/repository/seller.dart'; import 'package:possystem/routes.dart'; import 'package:possystem/settings/currency_setting.dart'; @@ -12,6 +14,7 @@ import 'package:provider/provider.dart'; import '../../mocks/mock_cache.dart'; import '../../mocks/mock_database.dart'; +import '../../mocks/mock_storage.dart'; import '../../test_helpers/translator.dart'; void main() { @@ -57,8 +60,17 @@ void main() { )).thenAnswer((_) => Future.value([{}])); } - testWidgets('navigate to history', (tester) async { - mockGetMetrics(); + void mockGetChart() { + when(database.query( + any, + columns: argThat(contains('SUM(t.price) price'), named: 'columns'), + orderBy: anyNamed('orderBy'), + escapeTable: anyNamed('escapeTable'), + groupBy: anyNamed('groupBy'), + )).thenAnswer((_) => Future.value([{}])); + } + + void mockGetOrder() { when(database.query( Seller.orderTable, columns: anyNamed('columns'), @@ -72,11 +84,18 @@ void main() { )).thenAnswer((_) => Future.value([])); when(database.query( any, - columns: argThat(equals(['t.day', 'COUNT(*) c']), named: 'columns'), + columns: anyNamed('columns'), groupBy: argThat(equals('t.day'), named: 'groupBy'), + orderBy: anyNamed('orderBy'), whereArgs: anyNamed('whereArgs'), escapeTable: false, )).thenAnswer((_) => Future.value([])); + } + + testWidgets('navigate to history', (tester) async { + mockGetMetrics(); + mockGetOrder(); + Analysis(); await tester.pumpWidget(buildApp()); await tester.pumpAndSettle(); @@ -85,9 +104,47 @@ void main() { await tester.pumpAndSettle(); }); + testWidgets('add chart', (tester) async { + Analysis(); + mockGetMetrics(); + mockGetChart(); + when(storage.add(any, any, any)).thenAnswer((_) => Future.value()); + + await tester.pumpWidget(buildApp()); + await tester.tap(find.byKey(const Key('anal.add_chart'))); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('chart.title')), 'test'); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('modal.save'))); + await tester.pumpAndSettle(); + + final chart = Analysis.instance.items.first; + verify(storage.add( + any, + argThat(equals(chart.id)), + argThat(predicate((data) => data is Map && data['name'] == 'test')), + )); + + expect(find.text('test'), findsOneWidget); + expect(find.byKey(Key('chart.${chart.id}.reset')), findsOneWidget); + expect(chart.name, equals('test')); + // verify default values + expect(chart.type.name, equals('cartesian')); + expect(chart.ignoreEmpty, equals(true)); + expect(chart.withToday, equals(false)); + expect(chart.range.duration, equals(const Duration(days: 7))); + expect(chart is CartesianChart, isTrue); + expect(chart.target, OrderMetricTarget.order); + expect(chart.metrics, equals(const [OrderMetricType.price])); + expect(chart.targetItems, isEmpty); + }); + setUpAll(() { initializeCache(); initializeDatabase(); + initializeStorage(); initializeTranslator(); }); }); diff --git a/test/ui/analysis/chart_card_view_test.dart b/test/ui/analysis/chart_card_view_test.dart new file mode 100644 index 00000000..0cb05c79 --- /dev/null +++ b/test/ui/analysis/chart_card_view_test.dart @@ -0,0 +1,503 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mockito/mockito.dart'; +import 'package:possystem/constants/icons.dart'; +import 'package:possystem/helpers/util.dart'; +import 'package:possystem/models/analysis/analysis.dart'; +import 'package:possystem/models/analysis/chart.dart'; +import 'package:possystem/models/menu/catalog.dart'; +import 'package:possystem/models/menu/product.dart'; +import 'package:possystem/models/order/order_attribute.dart'; +import 'package:possystem/models/order/order_attribute_option.dart'; +import 'package:possystem/models/repository/menu.dart'; +import 'package:possystem/models/repository/order_attributes.dart'; +import 'package:possystem/models/repository/seller.dart'; +import 'package:possystem/models/repository/stock.dart'; +import 'package:possystem/models/stock/ingredient.dart'; +import 'package:possystem/routes.dart'; +import 'package:possystem/ui/analysis/widgets/chart_card_view.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../../mocks/mock_database.dart'; +import '../../mocks/mock_storage.dart'; +import '../../test_helpers/translator.dart'; + +void main() { + group('Chart View', () { + Widget buildApp(Chart chart) { + Analysis().replaceItems({chart.id: chart}); + final view = ChartCardView(chart: chart); + return MaterialApp.router( + routerConfig: GoRouter(routes: [ + GoRoute( + path: '/', + routes: Routes.routes, + builder: (ctx, state) { + return Scaffold(body: view); + }, + ), + ]), + ); + } + + void mockGetItemMetricsInPeriod({ + Matcher? table, + Matcher? columns, + OrderMetricTarget? target, + List> rows = const [], + }) { + when(database.query( + argThat(table ?? anything), + columns: argThat(columns ?? anything, named: 'columns'), + groupBy: argThat( + target == null ? anything : equals("t.day, ${target.groupColumn}"), + named: 'groupBy', + ), + orderBy: anyNamed('orderBy'), + escapeTable: anyNamed('escapeTable'), + )).thenAnswer((_) async => rows); + } + + void mockGetMetricsInPeriod({ + Matcher? table, + Matcher? columns, + List> rows = const [], + }) { + when(database.query( + argThat(table ?? anything), + columns: argThat(columns ?? anything, named: 'columns'), + groupBy: argThat(equals("t.day"), named: 'groupBy'), + orderBy: anyNamed('orderBy'), + escapeTable: anyNamed('escapeTable'), + )).thenAnswer((_) async => rows); + } + + group('Cartesian Chart', () { + testWidgets('edit to product with selection', (tester) async { + final today = Util.toUTC(hour: 0); + final yesterday = today - 86400; + final tomorrow = today + 86400; + final sevenDaysAgo = today - 86400 * 7; + Menu().replaceItems({ + 'c1': Catalog(id: 'c1', name: 'c1') + ..replaceItems({ + 'p1': Product(id: 'p1', name: 'p1'), + 'p2': Product(id: 'p2', name: 'p2'), + }), + }); + mockGetMetricsInPeriod( + table: equals( + '(SELECT CAST((createdAt - $sevenDaysAgo) / 86400 AS INT) day, ' + '* FROM order_records WHERE createdAt BETWEEN $sevenDaysAgo AND $today) t'), + rows: [ + {'day': 1, 'price': 1.1, 'revenue': 2.2}, + {'day': 3, 'price': 1.2, 'revenue': 2.3}, + ]); + + await tester.pumpWidget(buildApp(CartesianChart(id: 'test'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('chart.test.more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(KIcons.modal)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('chart.title')), 'title2'); + await tester.tap(find.byKey(const Key('chart.withToday'))); + await tester.tap(find.byKey(const Key('chart.ignoreEmpty'))); + await tester.tap(find.byKey(const Key('chart.range.today'))); + await tester.tap(find.byKey(const Key('chart.type.circular'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('chart.type.cartesian'))); + await tester.pumpAndSettle(); + await tester.dragFrom(const Offset(500, 500), const Offset(0, -500)); + + await tester.tap(find.byKey(const Key('chart.metrics.count'))); + await tester.tap(find.byKey(const Key('chart.metrics.cost'))); + await tester.pumpAndSettle(); + + final types = OrderMetricType.values.where((type) { + final chip = find.byKey(Key('chart.metrics.${type.name}')).evaluate(); + return (chip.single.widget as ChoiceChip).selected; + }); + expect(types.map((e) => e.name).join(','), equals('price,cost,count')); + + await tester.tap(find.byKey(const Key('chart.metrics.cost'))); + await tester.pumpAndSettle(); + expect(types.map((e) => e.name).join(','), equals('price,count')); + + await tester.dragFrom(const Offset(500, 500), const Offset(0, -500)); + await tester.tap(find.byKey(const Key('chart.target.product'))); + await tester.pumpAndSettle(); + // reset + expect(types.map((e) => e.name).join(','), equals('price')); + await tester.tap(find.byKey(const Key('chart.metrics.cost'))); + + await tester.dragFrom(const Offset(500, 500), const Offset(0, -500)); + await tester.tap(find.byKey(const Key('chart.item.p1'))); + await tester.tap(find.byKey(const Key('chart.item.p2'))); + await tester.pumpAndSettle(); + + final items = ['p1', 'p2'].where((id) { + final chip = find.byKey(Key('chart.item.$id')).evaluate(); + return (chip.single.widget as ChoiceChip).selected; + }); + expect(items.join(','), equals('p1,p2')); + + await tester.tap(find.byKey(const Key('chart.item.p2'))); + await tester.pumpAndSettle(); + expect(items.join(','), equals('p1')); + + await tester.tap(find.byKey(const Key('chart.item_all'))); + await tester.tap(find.byKey(const Key('chart.item.p2'))); + await tester.pumpAndSettle(); + expect(items.join(','), equals('p2')); + + // withToday is true so the range should contain tomorrow: yesterday < t < tomorrow + mockGetItemMetricsInPeriod( + table: equals( + '(SELECT CAST((createdAt - $yesterday) / 3600 AS INT) day, * ' + 'FROM order_products WHERE createdAt BETWEEN $yesterday AND $tomorrow ' + 'AND productName IN ("p2") ) t'), + target: OrderMetricTarget.product, + columns: contains('SUM(t.singleCost * t.count) value'), + rows: [ + {'day': 1, 'name': 'p2', 'value': 1}, + {'day': 3, 'name': 'p2', 'value': 3}, + ], + ); + + await tester.tap(find.byKey(const Key('modal.save'))); + await tester.pumpAndSettle(); + + expect(find.text('title2'), findsOneWidget); + expect(find.text('p2', findRichText: true), findsOneWidget); + // `p1` should not exist only if selection not contains it. + expect(find.text('p1', findRichText: true), findsNothing); + + verify(storage.set( + any, + argThat(equals({ + 'test.name': 'title2', + 'test.withToday': true, + 'test.ignoreEmpty': false, + 'test.range': OrderChartRange.today.index, + 'test.target': OrderMetricTarget.product.index, + 'test.metrics': [OrderMetricType.cost.index], + 'test.targetItems': ['p2'], + })), + )); + }); + + testWidgets('edit to attributes without selection', (tester) async { + final today = Util.toUTC(hour: 0); + final sevenDaysAgo = today - 86400 * 7; + OrderAttributes().replaceItems({ + 'a1': OrderAttribute(id: 'a1', name: 'a1') + ..replaceItems({ + 'a1o1': OrderAttributeOption(id: 'a1o1', name: 'o1'), + 'a1o2': OrderAttributeOption(id: 'a1o2', name: 'o2'), + }) + ..prepareItem(), + 'a2': OrderAttribute(id: 'a2', name: 'a2') + ..replaceItems({ + 'a2o1': OrderAttributeOption(id: 'a2o1', name: 'o1'), + 'a2o2': OrderAttributeOption(id: 'a2o2', name: 'o2'), + }) + ..prepareItem(), + }); + mockGetMetricsInPeriod(); + + await tester.pumpWidget(buildApp(CartesianChart( + id: 'test', + ignoreEmpty: false, + ))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('chart.test.more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(KIcons.modal)); + await tester.pumpAndSettle(); + + await tester.dragFrom(const Offset(500, 500), const Offset(0, -500)); + await tester.tap(find.byKey(const Key('chart.target.attribute'))); + await tester.pumpAndSettle(); + + // withToday is true so the range should contain tomorrow: yesterday < t < tomorrow + mockGetItemMetricsInPeriod( + table: equals( + '(SELECT CAST((createdAt - $sevenDaysAgo) / 86400 AS INT) day, * ' + 'FROM order_attributes WHERE createdAt BETWEEN $sevenDaysAgo AND $today ) t'), + target: OrderMetricTarget.attribute, + ); + + await tester.tap(find.byKey(const Key('modal.save'))); + await tester.pumpAndSettle(); + + // all item should exist if select all. + expect(find.text('o1(a1)', findRichText: true), findsOneWidget); + expect(find.text('o2(a1)', findRichText: true), findsOneWidget); + expect(find.text('o1(a2)', findRichText: true), findsOneWidget); + expect(find.text('o2(a2)', findRichText: true), findsOneWidget); + + verify(storage.set( + any, + argThat(equals({ + 'test.metrics': [OrderMetricType.count.index], + 'test.target': OrderMetricTarget.attribute.index, + })), + )); + }); + + testWidgets('slide the range with price and count', (tester) async { + final today = Util.toUTC(hour: 0); + final sevenDaysAgo = today - 86400 * 7; + final fourteenDaysAgo = today - 86400 * 14; + mockGetMetricsInPeriod(rows: [ + {'day': 1, 'price': 1.1, 'count': 2}, + {'day': 2, 'price': 2.2, 'count': 3}, + ]); + + await tester.pumpWidget(buildApp(CartesianChart( + id: 'test', + metrics: [OrderMetricType.price, OrderMetricType.count], + ))); + await tester.pumpAndSettle(); + + void verifyContains(int days) { + verify(database.query( + argThat(contains('$days')), + columns: anyNamed('columns'), + groupBy: anyNamed('groupBy'), + orderBy: anyNamed('orderBy'), + escapeTable: anyNamed('escapeTable'), + )).called(1); + } + + verifyContains(sevenDaysAgo); + + await tester.tap(find.byIcon(Icons.arrow_back_ios_new_sharp)); + await tester.pumpAndSettle(); + verifyContains(fourteenDaysAgo); + + await tester.tap(find.byIcon(Icons.arrow_forward_ios_sharp)); + await tester.pumpAndSettle(); + verifyContains(sevenDaysAgo); + + // after today is not searchable + await tester.tap(find.byIcon(Icons.arrow_forward_ios_sharp)); + await tester.pumpAndSettle(); + verifyNever(database.query( + any, + columns: anyNamed('columns'), + groupBy: anyNamed('groupBy'), + orderBy: anyNamed('orderBy'), + escapeTable: anyNamed('escapeTable'), + )); + + await tester.tap(find.byIcon(Icons.refresh_sharp)); + await tester.pumpAndSettle(); + verifyContains(sevenDaysAgo); + }); + + testWidgets('365 day with all types and ignore drag', (tester) async { + mockGetMetricsInPeriod(rows: [ + {'day': 1, 'price': 1.1, 'revenue': 1.1, 'cost': 1.1, 'count': 2}, + {'day': 2, 'price': 2.2, 'revenue': 2.2, 'cost': 2.2, 'count': 3}, + ]); + + final chart = CartesianChart( + id: 'test', + metrics: OrderMetricType.values, + range: OrderChartRange.year, + ignoreEmpty: true, + ); + Analysis().replaceItems({chart.id: chart}); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + SizedBox(width: 300, child: ChartCardView(chart: chart)), + const SizedBox(width: 2000, height: 200), + ]), + ), + ), + )); + await tester.pumpAndSettle(); + + final charts = find.byType(SfCartesianChart).evaluate(); + expect(charts.length, equals(1)); + final axes = (charts.first.widget as SfCartesianChart).axes; + expect(axes.length, equals(2)); + expect(axes.first.name, equals(OrderMetricUnit.money.name)); + expect(axes.elementAt(1).name, equals(OrderMetricUnit.count.name)); + + // drag will be ignored to avoid TabView scrolling + await tester.dragFrom(const Offset(250, 300), const Offset(-200, 0)); + }); + }); + + void mockGetMetricsByItems({ + Matcher? table, + Matcher? where, + Matcher? whereArgs, + Matcher? groupBy, + List> rows = const [], + }) { + when(database.query( + argThat(table ?? anything), + columns: anyNamed('columns'), + where: argThat(where ?? anything, named: 'where'), + whereArgs: argThat(whereArgs ?? anything, named: 'whereArgs'), + groupBy: argThat(groupBy ?? anything, named: 'groupBy'), + orderBy: anyNamed('orderBy'), + )).thenAnswer((_) async => rows); + } + + group('Circular Chart', () { + testWidgets('edit to attribute with selection', (tester) async { + Stock().replaceItems({ + 'i1': Ingredient(id: 'i1', name: 'i1'), + 'i2': Ingredient(id: 'i2', name: 'i2'), + }); + OrderAttributes().replaceItems({ + 'a1': OrderAttribute(id: 'a1', name: 'a1') + ..replaceItems({ + 'a1o1': OrderAttributeOption(id: 'a1o1', name: 'a1o1'), + 'a1o2': OrderAttributeOption(id: 'a1o2', name: 'a1o2'), + }) + ..prepareItem(), + 'a2': OrderAttribute(id: 'a2', name: 'a2') + ..replaceItems({ + 'a2o1': OrderAttributeOption(id: 'a2o1', name: 'a2o1'), + 'a2o2': OrderAttributeOption(id: 'a2o2', name: 'a2o2'), + 'a2o3': OrderAttributeOption(id: 'a2o3', name: 'a2o3'), + }) + ..prepareItem(), + }); + mockGetMetricsByItems( + table: equals(OrderMetricTarget.ingredient.table), + where: equals('createdAt BETWEEN ? AND ?'), + rows: [ + {'name': 'i1', 'value': 1}, + ], + ); + + await tester.pumpWidget(buildApp(CircularChart( + id: 'test', + target: OrderMetricTarget.ingredient, + ))); + await tester.pumpAndSettle(); + + expect(find.text('i1', findRichText: true), findsOneWidget); + expect(find.text('i2', findRichText: true), findsNothing); + + await tester.tap(find.byKey(const Key('chart.test.more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(KIcons.modal)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byKey(const Key('chart.title')), 'title2'); + await tester.tap(find.byKey(const Key('chart.withToday'))); + await tester.tap(find.byKey(const Key('chart.ignoreEmpty'))); + await tester.tap(find.byKey(const Key('chart.range.today'))); + await tester.dragFrom(const Offset(500, 500), const Offset(0, -500)); + await tester.tap(find.byKey(const Key('chart.target.attribute'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('chart.metric.price')), findsNothing); + + await tester.dragFrom(const Offset(500, 500), const Offset(0, -300)); + await tester.dragFrom(const Offset(500, 500), const Offset(0, -300)); + await tester.tap(find.byKey(const Key('chart.item.a1'))); + await tester.tap(find.byKey(const Key('chart.item.a2'))); + await tester.pumpAndSettle(); + + // only one selected + mockGetMetricsByItems( + table: equals(OrderMetricTarget.attribute.table), + where: equals('createdAt BETWEEN ? AND ? AND name IN ("a2")'), + rows: [ + {'name': 'a2o1', 'value': 2}, + {'name': 'a2o2', 'value': 1}, + {'name': 'non-exist', 'value': 1}, + ], + ); + + await tester.tap(find.byKey(const Key('modal.save'))); + await tester.pumpAndSettle(); + + expect(find.text('a2o1', findRichText: true), findsOneWidget); + expect(find.text('a2o2', findRichText: true), findsOneWidget); + expect(find.text('a2o3', findRichText: true), findsOneWidget); + + verify(storage.set( + any, + argThat(equals({ + 'test.name': 'title2', + 'test.target': OrderMetricTarget.attribute.index, + 'test.targetItems': ['a2'], + 'test.ignoreEmpty': false, + 'test.withToday': true, + 'test.range': OrderChartRange.today.index, + })), + )); + }); + + testWidgets('update date by date-picker', (tester) async { + final today = Util.toUTC(hour: 0); + final yesterday = today - 86400; + final tomorrow = today + 86400; + Menu().replaceItems({ + 'c1': Catalog(id: 'c1', name: 'c1'), + 'c2': Catalog(id: 'c2', name: 'c2'), + }); + mockGetMetricsByItems( + whereArgs: equals([yesterday, tomorrow]), + rows: [ + {'name': 'c1', 'value': 1}, + ], + ); + + await tester.pumpWidget(buildApp(CircularChart( + id: 'test', + target: OrderMetricTarget.catalog, + range: OrderChartRange.today, + ignoreEmpty: true, + withToday: true, + ))); + await tester.pumpAndSettle(); + + expect(find.text('c1', findRichText: true), findsOneWidget); + expect(find.text('c2', findRichText: true), findsNothing); + + await tester.tap(find.byKey(const Key('chart.test.range_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + verify(database.query( + any, + columns: anyNamed('columns'), + where: anyNamed('where'), + whereArgs: argThat(equals([yesterday, tomorrow]), named: 'whereArgs'), + groupBy: anyNamed('groupBy'), + orderBy: anyNamed('orderBy'), + )); + }); + }); + }); + + setUpAll(() { + initializeDatabase(); + initializeStorage(); + initializeTranslator(); + VisibilityDetectorController.instance.updateInterval = Duration.zero; + }); + + tearDown(() { + reset(database); + reset(storage); + }); +} diff --git a/test/ui/analysis/goals_card_view_test.dart b/test/ui/analysis/goals_card_view_test.dart new file mode 100644 index 00000000..3a9e0ac9 --- /dev/null +++ b/test/ui/analysis/goals_card_view_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:possystem/helpers/analysis/ema_calculator.dart'; +import 'package:possystem/helpers/util.dart'; +import 'package:possystem/models/repository/seller.dart'; +import 'package:possystem/ui/analysis/widgets/goals_card_view.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../../mocks/mock_database.dart'; + +void main() { + Future>> mockQuery(int begin, int cease) { + return database.query( + argThat(contains('createdAt BETWEEN $begin AND $cease')), + columns: anyNamed('columns'), + groupBy: anyNamed('groupBy'), + orderBy: anyNamed('orderBy'), + escapeTable: anyNamed('escapeTable'), + ); + } + + group('Goals View', () { + testWidgets('EMA calculator should work correctly', (tester) async { + final today = Util.toUTC(hour: 0); + final tomorrow = today + 86400; + final twentyDaysAgo = today - 86400 * 20; + + when(mockQuery(twentyDaysAgo, tomorrow)).thenAnswer((_) async => [ + for (var i = 0; i < 21; i++) + { + 'day': i, + 'count': i, + 'price': i * 1.1, + 'revenue': i * 1.2, + 'cost': i * 1.3, + } + ]); + when(mockQuery(today, tomorrow)).thenAnswer((_) => Future.value([ + { + 'day': 0, + 'count': 2, + 'price': 2.1, + 'revenue': 2.2, + 'cost': 2.3, + } + ])); + + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: GoalsCardView()), + )); + await tester.pumpAndSettle(); + + void findText(String v) { + expect(find.text(v, findRichText: true), findsOneWidget); + } + + const calculator = EMACalculator(20); + final data = { + 'count': calculator.calculate([for (var i = 0; i < 20; i++) i]), + 'price': calculator.calculate([for (var i = 0; i < 20; i++) i * 1.1]), + 'revenue': calculator.calculate([for (var i = 0; i < 20; i++) i * 1.2]), + }; + findText('20/${data['count']!.toInt()}'); + findText('22/${data['price']!.prettyString()}'); + findText('24/${data['revenue']!.prettyString()}'); + verify(mockQuery(twentyDaysAgo, tomorrow)); + + // notify the seller to update the view + Seller.instance.notifyListeners(); + await tester.pumpAndSettle(); + + findText('2/${data['count']!.toInt()}'); + verify(mockQuery(today, tomorrow)); + verifyNever(mockQuery(twentyDaysAgo, tomorrow)); + }); + + setUpAll(() { + initializeDatabase(); + VisibilityDetectorController.instance.updateInterval = Duration.zero; + }); + }); +} diff --git a/test/ui/analysis/history_page_test.dart b/test/ui/analysis/history_page_test.dart index a8f75292..37012771 100644 --- a/test/ui/analysis/history_page_test.dart +++ b/test/ui/analysis/history_page_test.dart @@ -57,9 +57,12 @@ void main() { void mockGetCountPerDay(List> count) { when(database.query( any, - columns: argThat(equals(['t.day', 'COUNT(*) c']), named: 'columns'), + columns: argThat( + equals(['t.day', 'COUNT(t.price) count']), + named: 'columns', + ), groupBy: argThat(equals('t.day'), named: 'groupBy'), - whereArgs: anyNamed('whereArgs'), + orderBy: anyNamed('orderBy'), escapeTable: false, )).thenAnswer((_) => Future.value(count)); } @@ -78,8 +81,8 @@ void main() { OrderSetter.setOrders([o1, o2]); OrderSetter.setMetrics([o1, o2]); mockGetCountPerDay([ - {'day': nowS, 'c': 100}, - {'day': nowS - 1, 'c': 50}, + {'day': nowS, 'count': 100}, + {'day': nowS - 1, 'count': 50}, ]); // setup portrait env @@ -124,9 +127,9 @@ void main() { OrderSetter.setOrders([]); OrderSetter.setMetrics([]); mockGetCountPerDay([ - {'day': nowS, 'c': 50}, + {'day': nowS, 'count': 50}, // last month - {'day': nowS - now.day - 7, 'c': 60}, + {'day': nowS - now.day - 7, 'count': 60}, ]); // setup landscape env diff --git a/test/ui/cashier/cashier_view_test.dart b/test/ui/cashier/cashier_view_test.dart index 96b71b58..d909c9f5 100644 --- a/test/ui/cashier/cashier_view_test.dart +++ b/test/ui/cashier/cashier_view_test.dart @@ -136,11 +136,11 @@ void main() { } await action('1', '13'); - expect(find.text('13'), findsOneWidget); + expect(find.text('13/0'), findsOneWidget); await action('5', '2'); await action('5', '5', 'cancel'); - expect(find.text('2'), findsOneWidget); + expect(find.text('2/0'), findsOneWidget); await tester.tap(find.byKey(const Key('cashier.defaulter'))); await tester.pumpAndSettle(); diff --git a/test/ui/home/home_page_test.dart b/test/ui/home/home_page_test.dart index 320bfb40..737d4979 100644 --- a/test/ui/home/home_page_test.dart +++ b/test/ui/home/home_page_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mockito/mockito.dart'; import 'package:possystem/constants/app_themes.dart'; +import 'package:possystem/models/analysis/analysis.dart'; import 'package:possystem/models/repository/cart.dart'; import 'package:possystem/models/repository/cashier.dart'; import 'package:possystem/models/repository/order_attributes.dart'; @@ -33,10 +34,13 @@ void main() { argThat(predicate((key) => key.startsWith('tutorial.'))), )).thenReturn(true); when(database.query( - Seller.orderTable, + any, columns: anyNamed('columns'), + groupBy: anyNamed('groupBy'), + orderBy: anyNamed('orderBy'), where: anyNamed('where'), whereArgs: anyNamed('whereArgs'), + escapeTable: anyNamed('escapeTable'), )).thenAnswer((_) => Future.value([])); final settings = SettingsProvider(SettingsProvider.allSettings); @@ -48,6 +52,7 @@ void main() { ChangeNotifierProvider.value(value: Stock()), ChangeNotifierProvider.value(value: Quantities()), ChangeNotifierProvider.value(value: OrderAttributes()), + ChangeNotifierProvider.value(value: Analysis()), ChangeNotifierProvider.value(value: Cart()), ChangeNotifierProvider.value(value: Cashier()), ], diff --git a/test/ui/order/order_page_test.dart b/test/ui/order/order_page_test.dart index 8e00f65d..fc718ae2 100644 --- a/test/ui/order/order_page_test.dart +++ b/test/ui/order/order_page_test.dart @@ -236,6 +236,7 @@ void main() { await tester.drag( find.byKey(const Key('order.ds')), + // should below the window height: H - currentH < 300 const Offset(0, -300), ); await tester.pumpAndSettle(); @@ -247,6 +248,13 @@ void main() { expect(find.byKey(const Key('order.ingredient.noNeedIngredient')), findsOneWidget); + // full screen the panel + await tester.dragFrom( + tester.getCenter(find.byKey(const Key('cart.product_list'))), + const Offset(0, -200), + ); + await tester.pumpAndSettle(); + // select product await tester.tap(find.byKey(const Key('cart.product.0'))); await tester.pumpAndSettle(); diff --git a/test/ui/stock/stock_view_test.dart b/test/ui/stock/stock_view_test.dart index 064dea0d..485ecfa8 100644 --- a/test/ui/stock/stock_view_test.dart +++ b/test/ui/stock/stock_view_test.dart @@ -151,8 +151,8 @@ void main() { ], child: buildApp())); // correctly transform string - expect(find.text('5.4e+4'), findsOneWidget); - expect(find.text('900.6'), findsOneWidget); + expect(find.text('0/5.4e+4'), findsOneWidget); + expect(find.text('0/900.6'), findsOneWidget); final ingredient = Stock.instance.items.first; diff --git a/test/ui/transit/google_sheet/export_order_test.dart b/test/ui/transit/google_sheet/export_order_test.dart index 0f363f08..1cbfb605 100644 --- a/test/ui/transit/google_sheet/export_order_test.dart +++ b/test/ui/transit/google_sheet/export_order_test.dart @@ -101,7 +101,7 @@ void main() { // xx/01-xx/05 await tester.tap(find.text('1').first); await tester.tap(find.text('5').first); - await tester.tap(find.text('SAVE')); // material 2 + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); final expected = DateTimeRange(