diff --git a/assets/l10n/en/printer.yaml b/assets/l10n/en/printer.yaml index 065b4af3..a273bc7a 100644 --- a/assets/l10n/en/printer.yaml +++ b/assets/l10n/en/printer.yaml @@ -110,6 +110,54 @@ receipt: paid: Paid price: Price change: Change + editor: + title: Customize Receipt + empty: No receipt templates yet + createFirst: Create First Template + template: + title: + create: Create Receipt Template + update: Edit Receipt Template + name: + label: Template Name + errorRepeat: Template name already exists + toDefault: + label: Enable This Template + helper: Only one template can be enabled at a time + confirmChangeTitle: Change Default Template? + confirmChangeContent: This will disable the current default template "{name}". Continue? + editComponents: Edit Components + component: + count: + - =1: '{count} component' + other: '{count} components' + - count: {type: int, mode: plural, format: compactLong} + addTitle: Add Component + orderTable: Order Table + textField: Text Field + divider: Divider + timestamp: Order Timestamp + orderId: Order ID + totalSection: Total Section + paymentSection: Payment Section + text: Text + fontSize: Font Size + height: Height + alignment: Alignment + alignLeft: Left + alignCenter: Center + alignRight: Right + dateFormat: Date Format + dateFormatFull: Full (Date & Time) + dateFormatDate: Date Only + dateFormatTime: Time Only + showProductName: Show Product Name + showCatalogName: Show Catalog Name + orderIdDesc: Display order identifier + paymentDesc: Show payment details + editorReset: Reset + editorResetTitle: Reset to Default? + editorResetContent: This will restore the default component layout. All customizations will be lost. info: title: Printer Information name: Name diff --git a/assets/l10n/zh/printer.yaml b/assets/l10n/zh/printer.yaml index 616bf959..e1a4cb22 100644 --- a/assets/l10n/zh/printer.yaml +++ b/assets/l10n/zh/printer.yaml @@ -101,6 +101,54 @@ receipt: paid: 付額 price: 總價 change: 找錢 + editor: + title: 自訂收據格式 + empty: 尚無收據範本 + createFirst: 建立第一個範本 + template: + title: + create: 建立收據範本 + update: 編輯收據範本 + name: + label: 範本名稱 + errorRepeat: 範本名稱已存在 + toDefault: + label: 啟用此範本 + helper: 一次只能啟用一個範本 + confirmChangeTitle: 變更預設範本? + confirmChangeContent: 這將停用目前的預設範本「{name}」。繼續? + editComponents: 編輯元件 + component: + count: + - =1: '{count} 個元件' + other: '{count} 個元件' + - count: {type: int, mode: plural, format: compactLong} + addTitle: 新增元件 + orderTable: 訂單表格 + textField: 文字欄位 + divider: 分隔線 + timestamp: 訂單時間戳記 + orderId: 訂單編號 + totalSection: 總計區塊 + paymentSection: 付款區塊 + text: 文字 + fontSize: 字型大小 + height: 高度 + alignment: 對齊方式 + alignLeft: 靠左 + alignCenter: 置中 + alignRight: 靠右 + dateFormat: 日期格式 + dateFormatFull: 完整(日期與時間) + dateFormatDate: 僅日期 + dateFormatTime: 僅時間 + showProductName: 顯示產品名稱 + showCatalogName: 顯示目錄名稱 + orderIdDesc: 顯示訂單識別碼 + paymentDesc: 顯示付款詳情 + editorReset: 重置 + editorResetTitle: 重置為預設? + editorResetContent: 這將恢復預設的元件佈局。所有自訂設定將會遺失。 info: title: 出單機資訊 name: 名稱 diff --git a/lib/main.dart b/lib/main.dart index 41675050..8b792ef0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'models/repository/cashier.dart'; import 'models/repository/menu.dart'; import 'models/repository/order_attributes.dart'; import 'models/repository/quantities.dart'; +import 'models/repository/receipt_templates.dart'; import 'models/repository/replenisher.dart'; import 'models/repository/seller.dart'; import 'models/repository/stock.dart'; @@ -65,6 +66,7 @@ void main() async { await Stock().initialize(); await Quantities().initialize(); await OrderAttributes().initialize(); + await ReceiptTemplates().initialize(); await Replenisher().initialize(); await Cashier().reset(); await Analysis().initialize(); @@ -86,6 +88,7 @@ void main() async { ChangeNotifierProvider.value(value: Cashier.instance), ChangeNotifierProvider.value(value: Cart.instance), ChangeNotifierProvider.value(value: Printers.instance), + ChangeNotifierProvider.value(value: ReceiptTemplates.instance), ], child: const App(), )); diff --git a/lib/models/objects/receipt_template_object.dart b/lib/models/objects/receipt_template_object.dart new file mode 100644 index 00000000..4da0dce3 --- /dev/null +++ b/lib/models/objects/receipt_template_object.dart @@ -0,0 +1,57 @@ +import 'package:possystem/models/model_object.dart'; +import 'package:possystem/models/receipt_component.dart'; +import 'package:possystem/models/repository/receipt_templates.dart'; + +class ReceiptTemplateObject extends ModelObject { + final String? id; + final String? name; + final bool? isDefault; + final List? components; + + ReceiptTemplateObject({ + this.id, + this.name, + this.isDefault, + this.components, + }); + + @override + Map toMap() { + return { + 'name': name!, + 'isDefault': isDefault!, + 'components': components!.map((c) => c.toJson()).toList(), + }; + } + + @override + Map diff(ReceiptTemplate model) { + final result = {}; + final prefix = model.prefix; + + if (name != null && name != model.name) { + model.name = name!; + result['$prefix.name'] = name!; + } + if (isDefault != null && isDefault != model.isDefault) { + model.isDefault = isDefault!; + result['$prefix.isDefault'] = isDefault!; + } + if (components != null) { + model.components = components!; + result['$prefix.components'] = components!.map((c) => c.toJson()).toList(); + } + + return result; + } + + factory ReceiptTemplateObject.build(Map data) { + final componentsList = data['components'] as List?; + return ReceiptTemplateObject( + id: data['id'] as String, + name: data['name'] as String, + isDefault: data['isDefault'] as bool, + components: componentsList?.map((e) => ReceiptComponent.fromJson(e as Map)).toList(), + ); + } +} diff --git a/lib/models/receipt_component.dart b/lib/models/receipt_component.dart new file mode 100644 index 00000000..72e9beab --- /dev/null +++ b/lib/models/receipt_component.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; + +/// Base class for all receipt components +abstract class ReceiptComponent { + final String id; + final ReceiptComponentType type; + + ReceiptComponent({ + required this.id, + required this.type, + }); + + /// Convert to JSON for storage + Map toJson(); + + /// Create from JSON + factory ReceiptComponent.fromJson(Map json) { + final type = ReceiptComponentType.values[json['type'] as int]; + switch (type) { + case ReceiptComponentType.orderTable: + return OrderTableComponent.fromJson(json); + case ReceiptComponentType.textField: + return TextFieldComponent.fromJson(json); + case ReceiptComponentType.divider: + return DividerComponent.fromJson(json); + case ReceiptComponentType.orderTimestamp: + return OrderTimestampComponent.fromJson(json); + case ReceiptComponentType.orderId: + return OrderIdComponent.fromJson(json); + case ReceiptComponentType.totalSection: + return TotalSectionComponent.fromJson(json); + case ReceiptComponentType.paymentSection: + return PaymentSectionComponent.fromJson(json); + } + } + + /// Create a copy with updated properties + ReceiptComponent copyWith(); +} + +enum ReceiptComponentType { + orderTable, + textField, + divider, + orderTimestamp, + orderId, + totalSection, + paymentSection, +} + +/// Order table component with customizable columns +class OrderTableComponent extends ReceiptComponent { + final bool showProductName; + final bool showCatalogName; + final bool showCount; + final bool showPrice; + final bool showTotal; + + OrderTableComponent({ + required super.id, + this.showProductName = true, + this.showCatalogName = false, + this.showCount = true, + this.showPrice = true, + this.showTotal = true, + }) : super(type: ReceiptComponentType.orderTable); + + factory OrderTableComponent.fromJson(Map json) { + return OrderTableComponent( + id: json['id'] as String, + showProductName: json['showProductName'] as bool? ?? true, + showCatalogName: json['showCatalogName'] as bool? ?? false, + showCount: json['showCount'] as bool? ?? true, + showPrice: json['showPrice'] as bool? ?? true, + showTotal: json['showTotal'] as bool? ?? true, + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': type.index, + 'showProductName': showProductName, + 'showCatalogName': showCatalogName, + 'showCount': showCount, + 'showPrice': showPrice, + 'showTotal': showTotal, + }; + } + + @override + OrderTableComponent copyWith({ + bool? showProductName, + bool? showCatalogName, + bool? showCount, + bool? showPrice, + bool? showTotal, + }) { + return OrderTableComponent( + id: id, + showProductName: showProductName ?? this.showProductName, + showCatalogName: showCatalogName ?? this.showCatalogName, + showCount: showCount ?? this.showCount, + showPrice: showPrice ?? this.showPrice, + showTotal: showTotal ?? this.showTotal, + ); + } +} + +/// Custom text field component +class TextFieldComponent extends ReceiptComponent { + final String text; + final double fontSize; + final TextAlign textAlign; + + TextFieldComponent({ + required super.id, + required this.text, + this.fontSize = 14.0, + this.textAlign = TextAlign.left, + }) : super(type: ReceiptComponentType.textField); + + factory TextFieldComponent.fromJson(Map json) { + return TextFieldComponent( + id: json['id'] as String, + text: json['text'] as String? ?? '', + fontSize: json['fontSize'] as double? ?? 14.0, + textAlign: TextAlign.values[json['textAlign'] as int? ?? 0], + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': type.index, + 'text': text, + 'fontSize': fontSize, + 'textAlign': textAlign.index, + }; + } + + @override + TextFieldComponent copyWith({ + String? text, + double? fontSize, + TextAlign? textAlign, + }) { + return TextFieldComponent( + id: id, + text: text ?? this.text, + fontSize: fontSize ?? this.fontSize, + textAlign: textAlign ?? this.textAlign, + ); + } +} + +/// Divider component +class DividerComponent extends ReceiptComponent { + final double height; + + DividerComponent({ + required super.id, + this.height = 4.0, + }) : super(type: ReceiptComponentType.divider); + + factory DividerComponent.fromJson(Map json) { + return DividerComponent( + id: json['id'] as String, + height: json['height'] as double? ?? 4.0, + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': type.index, + 'height': height, + }; + } + + @override + DividerComponent copyWith({ + double? height, + }) { + return DividerComponent( + id: id, + height: height ?? this.height, + ); + } +} + +/// Order timestamp component with customizable format +class OrderTimestampComponent extends ReceiptComponent { + final String dateFormat; + + OrderTimestampComponent({ + required super.id, + this.dateFormat = 'yMMMd Hms', + }) : super(type: ReceiptComponentType.orderTimestamp); + + factory OrderTimestampComponent.fromJson(Map json) { + return OrderTimestampComponent( + id: json['id'] as String, + dateFormat: json['dateFormat'] as String? ?? 'yMMMd Hms', + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': type.index, + 'dateFormat': dateFormat, + }; + } + + @override + OrderTimestampComponent copyWith({ + String? dateFormat, + }) { + return OrderTimestampComponent( + id: id, + dateFormat: dateFormat ?? this.dateFormat, + ); + } +} + +/// Order ID component +class OrderIdComponent extends ReceiptComponent { + final double fontSize; + + OrderIdComponent({ + required super.id, + this.fontSize = 14.0, + }) : super(type: ReceiptComponentType.orderId); + + factory OrderIdComponent.fromJson(Map json) { + return OrderIdComponent( + id: json['id'] as String, + fontSize: json['fontSize'] as double? ?? 14.0, + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': type.index, + 'fontSize': fontSize, + }; + } + + @override + OrderIdComponent copyWith({ + double? fontSize, + }) { + return OrderIdComponent( + id: id, + fontSize: fontSize ?? this.fontSize, + ); + } +} + +/// Total section showing discounts and add-ons +class TotalSectionComponent extends ReceiptComponent { + final bool showDiscounts; + final bool showAddOns; + + TotalSectionComponent({ + required super.id, + this.showDiscounts = true, + this.showAddOns = true, + }) : super(type: ReceiptComponentType.totalSection); + + factory TotalSectionComponent.fromJson(Map json) { + return TotalSectionComponent( + id: json['id'] as String, + showDiscounts: json['showDiscounts'] as bool? ?? true, + showAddOns: json['showAddOns'] as bool? ?? true, + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': type.index, + 'showDiscounts': showDiscounts, + 'showAddOns': showAddOns, + }; + } + + @override + TotalSectionComponent copyWith({ + bool? showDiscounts, + bool? showAddOns, + }) { + return TotalSectionComponent( + id: id, + showDiscounts: showDiscounts ?? this.showDiscounts, + showAddOns: showAddOns ?? this.showAddOns, + ); + } +} + +/// Payment section showing paid, price, and change +class PaymentSectionComponent extends ReceiptComponent { + PaymentSectionComponent({ + required super.id, + }) : super(type: ReceiptComponentType.paymentSection); + + factory PaymentSectionComponent.fromJson(Map json) { + return PaymentSectionComponent( + id: json['id'] as String, + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': type.index, + }; + } + + @override + PaymentSectionComponent copyWith() { + return PaymentSectionComponent( + id: id, + ); + } +} diff --git a/lib/models/repository/receipt_template.dart b/lib/models/repository/receipt_template.dart new file mode 100644 index 00000000..08a61c8a --- /dev/null +++ b/lib/models/repository/receipt_template.dart @@ -0,0 +1,77 @@ +import 'package:possystem/models/model.dart'; +import 'package:possystem/models/objects/receipt_template_object.dart'; +import 'package:possystem/models/receipt_component.dart'; +import 'package:possystem/models/repository/receipt_templates.dart'; +import 'package:possystem/services/storage.dart'; + +class ReceiptTemplate extends Model with ModelStorage { + bool isDefault; + List components; + + @override + final Stores storageStore = Stores.receiptTemplates; + + @override + ReceiptTemplates get repository => ReceiptTemplates.instance; + + @override + String get prefix => 'template.$id'; + + ReceiptTemplate({ + super.id, + super.status = ModelStatus.normal, + super.name = 'receipt template', + this.isDefault = false, + List? components, + }) : components = components ?? _getDefaultComponents(); + + factory ReceiptTemplate.fromObject(ReceiptTemplateObject object) => ReceiptTemplate( + id: object.id, + name: object.name!, + isDefault: object.isDefault!, + components: object.components, + ); + + /// Get default receipt components matching the current hardcoded layout + static List _getDefaultComponents() { + return [ + TextFieldComponent( + id: 'title', + text: 'Receipt', + fontSize: 24.0, + textAlign: TextAlign.center, + ), + OrderTimestampComponent( + id: 'timestamp', + dateFormat: 'yMMMd Hms', + ), + DividerComponent(id: 'divider1', height: 4.0), + OrderTableComponent( + id: 'order_table', + showProductName: true, + showCatalogName: false, + showCount: true, + showPrice: true, + showTotal: true, + ), + DividerComponent(id: 'divider2', height: 4.0), + TotalSectionComponent( + id: 'total_section', + showDiscounts: true, + showAddOns: true, + ), + DividerComponent(id: 'divider3', height: 4.0), + PaymentSectionComponent(id: 'payment_section'), + ]; + } + + @override + ReceiptTemplateObject toObject() { + return ReceiptTemplateObject( + id: id, + name: name, + isDefault: isDefault, + components: components, + ); + } +} diff --git a/lib/models/repository/receipt_templates.dart b/lib/models/repository/receipt_templates.dart new file mode 100644 index 00000000..96fb5a1a --- /dev/null +++ b/lib/models/repository/receipt_templates.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:possystem/models/model.dart'; +import 'package:possystem/models/objects/receipt_template_object.dart'; +import 'package:possystem/models/receipt_component.dart'; +import 'package:possystem/models/repository.dart'; +import 'package:possystem/models/repository/receipt_template.dart'; +import 'package:possystem/services/storage.dart'; + +class ReceiptTemplates extends ChangeNotifier with Repository, RepositoryStorage { + static late ReceiptTemplates instance; + + @override + final Stores storageStore = Stores.receiptTemplates; + + ReceiptTemplates() { + instance = this; + } + + @override + List get itemList => items.toList(); + + @override + RepositoryStorageType get repoType => RepositoryStorageType.repoProperties; + + ReceiptTemplate? get defaultTemplate { + try { + return items.firstWhere((template) => template.isDefault); + } catch (e) { + return null; + } + } + + /// Get the current enabled template's components, or default if none enabled + List get currentComponents { + final template = defaultTemplate; + if (template != null) { + return template.components; + } + // Return default components if no template is enabled + return ReceiptTemplate._getDefaultComponents(); + } + + Future clearDefault() async { + final template = defaultTemplate; + + if (template != null) { + await template.update(ReceiptTemplateObject( + isDefault: false, + // Keep other values + name: template.name, + components: template.components, + )); + } + } + + @override + ReceiptTemplate buildItem(String id, Map value) { + return ReceiptTemplate.fromObject( + ReceiptTemplateObject.build({ + 'id': id, + ...value, + }), + ); + } + + @override + Future initialize({String? record}) async { + await super.initialize(record: 'template'); + + // Create default template if empty + if (isEmpty) { + await addItem(ReceiptTemplate( + name: 'Default Template', + isDefault: true, + components: ReceiptTemplate._getDefaultComponents(), + )); + } + } +} diff --git a/lib/routes.dart b/lib/routes.dart index c087ac22..d9f1b498 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -49,6 +49,10 @@ import 'package:possystem/ui/order_attr/widgets/order_attribute_reorder.dart'; import 'package:possystem/ui/printer/printer_modal.dart'; import 'package:possystem/ui/printer/printer_page.dart'; import 'package:possystem/ui/printer/printer_settings_modal.dart'; +import 'package:possystem/ui/printer/receipt_editor_page.dart'; +import 'package:possystem/ui/printer/widgets/receipt_template_component_editor_page.dart'; +import 'package:possystem/ui/printer/widgets/receipt_template_modal.dart'; +import 'package:possystem/models/repository/receipt_templates.dart'; import 'package:possystem/ui/stock/quantities_page.dart'; import 'package:possystem/ui/stock/replenishment_page.dart'; import 'package:possystem/ui/stock/stock_view.dart'; @@ -509,6 +513,45 @@ class Routes { parentNavigatorKey: rootNavigatorKey, pageBuilder: (ctx, state) => MaterialDialogPage(child: _l(const PrinterSettingsModal(), state)), ), + GoRoute( + name: printerReceiptEditor, + path: 'receipt-editor', + parentNavigatorKey: rootNavigatorKey, + builder: (ctx, state) => _l(const ReceiptEditorPage(), state), + routes: [ + GoRoute( + name: printerReceiptTemplateCreate, + path: 'create', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (ctx, state) => MaterialDialogPage(child: _l(const ReceiptTemplateModal(), state)), + ), + GoRoute( + path: 't/:id', + parentNavigatorKey: rootNavigatorKey, + redirect: _redirectIfMissed(path: 'receipt-editor', hasItem: (id) => ReceiptTemplates.instance.hasItem(id)), + routes: [ + GoRoute( + name: printerReceiptTemplateUpdate, + path: 'update', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (ctx, state) { + final template = ReceiptTemplates.instance.getItem(state.pathParameters['id']!)!; + return MaterialDialogPage(child: _l(ReceiptTemplateModal(template: template), state)); + }, + ), + GoRoute( + name: printerReceiptTemplateComponentEditor, + path: 'components', + parentNavigatorKey: rootNavigatorKey, + builder: (ctx, state) { + final template = ReceiptTemplates.instance.getItem(state.pathParameters['id']!)!; + return _l(ReceiptTemplateComponentEditorPage(template: template), state); + }, + ), + ], + ), + ], + ), GoRoute( path: 'a/:id', parentNavigatorKey: rootNavigatorKey, @@ -706,6 +749,10 @@ class Routes { static const printer = 'printer'; static const printerCreate = 'printer.create'; static const printerSettings = 'printer.settings'; + static const printerReceiptEditor = 'printer.receiptEditor'; + static const printerReceiptTemplateCreate = 'printer.receiptTemplate.create'; + static const printerReceiptTemplateUpdate = 'printer.receiptTemplate.update'; + static const printerReceiptTemplateComponentEditor = 'printer.receiptTemplate.componentEditor'; static const printerUpdate = 'printer.update'; } diff --git a/lib/services/storage.dart b/lib/services/storage.dart index 4dde07d4..de260b60 100644 --- a/lib/services/storage.dart +++ b/lib/services/storage.dart @@ -156,6 +156,7 @@ enum Stores { orderAttributes, analysis, printers, + receiptTemplates, } class StorageSanitizedValue { diff --git a/lib/ui/printer/printer_settings_modal.dart b/lib/ui/printer/printer_settings_modal.dart index bebe5a50..8db34e63 100644 --- a/lib/ui/printer/printer_settings_modal.dart +++ b/lib/ui/printer/printer_settings_modal.dart @@ -4,6 +4,7 @@ import 'package:possystem/components/scaffold/item_modal.dart'; import 'package:possystem/components/style/hint_text.dart'; import 'package:possystem/constants/constant.dart'; import 'package:possystem/models/printer.dart'; +import 'package:possystem/routes.dart'; import 'package:possystem/services/bluetooth.dart'; import 'package:possystem/translator.dart'; @@ -29,6 +30,15 @@ class _PrinterSettingsModalState extends State with ItemMo value: density == PrinterDensity.tight, onChanged: (value) => setState(() => density = value ? PrinterDensity.tight : PrinterDensity.normal), ), + const SizedBox(height: kInternalSpacing), + ListTile( + leading: const Icon(Icons.receipt_long), + title: Text(S.printerReceiptEditorTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.pushNamed(Routes.printerReceiptEditor); + }, + ), const SizedBox(height: kInternalLargeSpacing), Center(child: HintText(S.printerSettingsMore)), ]; diff --git a/lib/ui/printer/receipt_editor_page.dart b/lib/ui/printer/receipt_editor_page.dart new file mode 100644 index 00000000..13293f69 --- /dev/null +++ b/lib/ui/printer/receipt_editor_page.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:possystem/components/style/buttons.dart'; +import 'package:possystem/models/receipt_component.dart'; +import 'package:possystem/models/repository/receipt_template.dart'; +import 'package:possystem/models/repository/receipt_templates.dart'; +import 'package:possystem/routes.dart'; +import 'package:possystem/translator.dart'; +import 'package:possystem/ui/printer/widgets/receipt_template_modal.dart'; + +class ReceiptEditorPage extends StatelessWidget { + const ReceiptEditorPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(S.printerReceiptEditorTitle), + ), + body: ListenableBuilder( + listenable: ReceiptTemplates.instance, + builder: (context, _) { + final templates = ReceiptTemplates.instance.itemList; + if (templates.isEmpty) { + return _EmptyView(onCreate: () => _createTemplate(context)); + } + return ListView.builder( + itemCount: templates.length, + itemBuilder: (context, index) { + final template = templates[index]; + return _TemplateTile( + template: template, + onTap: () => _editTemplate(context, template), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _createTemplate(context), + child: const Icon(Icons.add), + ), + ); + } + + void _createTemplate(BuildContext context) { + context.pushNamed( + Routes.printerReceiptTemplateCreate, + ); + } + + void _editTemplate(BuildContext context, ReceiptTemplate template) { + context.pushNamed( + Routes.printerReceiptTemplateUpdate, + pathParameters: {'id': template.id}, + ); + } +} + +class _EmptyView extends StatelessWidget { + final VoidCallback onCreate; + + const _EmptyView({required this.onCreate}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.receipt_long, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + S.printerReceiptEditorEmpty, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add), + label: Text(S.printerReceiptEditorCreateFirst), + ), + ], + ), + ); + } +} + +class _TemplateTile extends StatelessWidget { + final ReceiptTemplate template; + final VoidCallback onTap; + + const _TemplateTile({ + required this.template, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon( + template.isDefault ? Icons.check_circle : Icons.radio_button_unchecked, + color: template.isDefault ? Theme.of(context).colorScheme.primary : null, + ), + title: Text(template.name), + subtitle: Text(S.printerReceiptComponentCount(template.components.length)), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } +} diff --git a/lib/ui/printer/widgets/printer_receipt_view.dart b/lib/ui/printer/widgets/printer_receipt_view.dart index bcd1fcee..7505a5d2 100644 --- a/lib/ui/printer/widgets/printer_receipt_view.dart +++ b/lib/ui/printer/widgets/printer_receipt_view.dart @@ -4,16 +4,310 @@ import 'package:possystem/components/imageable_container.dart'; import 'package:possystem/components/models/order_attribute_value_widget.dart'; import 'package:possystem/helpers/util.dart'; import 'package:possystem/models/objects/order_object.dart'; +import 'package:possystem/models/receipt_component.dart'; +import 'package:possystem/models/repository/receipt_templates.dart'; import 'package:possystem/translator.dart'; class PrinterReceiptView extends StatelessWidget { final OrderObject order; final ImageableController controller; + final List? customComponents; const PrinterReceiptView({ super.key, required this.order, required this.controller, + this.customComponents, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).textTheme; + final color = Theme.of(context).colorScheme; + final discounted = order.products.where((e) => e.isDiscount); + final attributes = order.attributes + .where((e) => e.modeValue != null) + .map((e) => [ + e.optionName, + OrderAttributeValueWidget.string(e.mode, e.modeValue!), + ]) + .toList(); + const text = Color(0xFF424242); + + // Use custom components if provided, otherwise use default from repository + final components = customComponents ?? ReceiptTemplates.instance.currentComponents; + + final children = components.map((component) { + return _buildComponent(component, theme, color, text, discounted, attributes); + }).toList(); + + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling), + child: Container( + constraints: const BoxConstraints(maxHeight: 400), + // wider width can result low density of receipt, since the paper + // is fixed width (58mm or 80mm). + width: 320, // fixed width can provide same density of receipt + child: ImageableContainer(controller: controller, children: children), + ), + ); + } + + Widget _buildComponent( + ReceiptComponent component, + TextTheme theme, + ColorScheme color, + Color text, + Iterable discounted, + List> attributes, + ) { + switch (component.type) { + case ReceiptComponentType.textField: + final c = component as TextFieldComponent; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + c.text, + style: theme.bodyMedium?.copyWith(fontSize: c.fontSize, color: text), + textAlign: c.textAlign, + ), + ); + case ReceiptComponentType.orderTimestamp: + final c = component as OrderTimestampComponent; + DateFormat format; + try { + // Parse custom format + final parts = c.dateFormat.split(' '); + format = DateFormat.yMMMd(); + for (final part in parts) { + if (part == 'Hms') { + format.addPattern(' ').add_Hms(); + } + } + } catch (e) { + format = DateFormat.yMMMd().addPattern(' ').add_Hms(); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + format.format(order.createdAt), + textAlign: TextAlign.center, + style: theme.bodyMedium?.copyWith(color: text), + ), + ); + case ReceiptComponentType.orderId: + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + 'Order ID: ${order.id ?? order.createdAt.millisecondsSinceEpoch}', + style: theme.bodyMedium?.copyWith(color: text), + ), + ); + case ReceiptComponentType.divider: + final c = component as DividerComponent; + return SizedBox(height: c.height); + case ReceiptComponentType.orderTable: + final c = component as OrderTableComponent; + return _buildOrderTable(c, theme, color, text); + case ReceiptComponentType.totalSection: + final c = component as TotalSectionComponent; + return _buildTotalSection(c, theme, text, discounted, attributes); + case ReceiptComponentType.paymentSection: + return _buildPaymentSection(theme, text); + } + } + + Widget _buildOrderTable(OrderTableComponent config, TextTheme theme, ColorScheme color, Color text) { + final columns = {}; + int colIndex = 0; + + if (config.showProductName || config.showCatalogName) { + columns[colIndex++] = const FlexColumnWidth(); + } + if (config.showCount) { + columns[colIndex++] = const MaxColumnWidth(FractionColumnWidth(0.1), IntrinsicColumnWidth()); + } + if (config.showPrice) { + columns[colIndex++] = const MaxColumnWidth(FractionColumnWidth(0.1), IntrinsicColumnWidth()); + } + if (config.showTotal) { + columns[colIndex++] = const MaxColumnWidth(FractionColumnWidth(0.2), IntrinsicColumnWidth()); + } + + return DefaultTextStyle( + style: theme.bodyMedium!.copyWith(height: 1.8, overflow: TextOverflow.clip, color: text), + child: Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + columnWidths: columns, + border: TableBorder( + horizontalInside: BorderSide(color: color.outlineVariant), + top: BorderSide(color: color.outline), + bottom: BorderSide(color: color.outline), + ), + children: [ + TableRow( + children: [ + if (config.showProductName || config.showCatalogName) + TableCell(child: Text(S.printerReceiptColumnName)), + if (config.showCount) TableCell(child: Text(S.printerReceiptColumnCount, textAlign: TextAlign.end)), + if (config.showPrice) TableCell(child: Text(S.printerReceiptColumnPrice, textAlign: TextAlign.end)), + if (config.showTotal) TableCell(child: Text(S.printerReceiptColumnTotal, textAlign: TextAlign.end)), + ], + ), + for (final product in order.products) + TableRow( + children: [ + if (config.showProductName || config.showCatalogName) + TableCell( + child: Text(config.showCatalogName ? product.catalogName : product.productName), + ), + if (config.showCount) TableCell(child: Text(product.count.toString(), textAlign: TextAlign.end)), + if (config.showPrice) + TableCell(child: Text('\$${product.singlePrice.toCurrency()}', textAlign: TextAlign.end)), + if (config.showTotal) + TableCell(child: Text('\$${product.totalPrice.toCurrency()}', textAlign: TextAlign.end)), + ], + ), + ], + ), + ); + } + + Widget _buildTotalSection( + TotalSectionComponent config, + TextTheme theme, + Color text, + Iterable discounted, + List> attributes, + ) { + final children = []; + + if (config.showDiscounts && discounted.isNotEmpty) { + children.add( + TableRow(children: [ + TableCell(child: Text(S.printerReceiptDiscountLabel)), + TableCell(child: Text(S.printerReceiptDiscountOrigin)), + ]), + ); + for (final product in discounted) { + children.add( + TableRow(children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(product.productName), + ), + ), + TableCell( + child: Text( + '\$${product.originalPrice.toCurrency()}', + style: theme.labelMedium?.copyWith(color: text), + textAlign: TextAlign.end, + ), + ), + ]), + ); + } + if (config.showAddOns && attributes.isNotEmpty) { + children.add( + const TableRow(children: [ + TableCell(child: SizedBox(height: 4.0)), + TableCell(child: SizedBox(height: 4.0)), + ]), + ); + } + } + + if (config.showAddOns && attributes.isNotEmpty) { + children.add( + TableRow(children: [ + TableCell(child: Text(S.printerReceiptAddOnsLabel)), + TableCell(child: Text(S.printerReceiptAddOnsAdjustment)), + ]), + ); + for (final attr in attributes) { + children.add( + TableRow(children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(attr[0]), + ), + ), + TableCell( + child: Text( + attr[1], + style: theme.labelMedium?.copyWith(color: text), + textAlign: TextAlign.end, + ), + ), + ]), + ); + } + } + + children.add( + TableRow(children: [ + TableCell(child: Text(S.printerReceiptTotal)), + TableCell( + child: Text( + '\$${order.price.toCurrency()}', + style: theme.titleLarge?.copyWith(color: text), + ), + ), + ]), + ); + + return Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + columnWidths: const { + 0: FlexColumnWidth(), + 1: MaxColumnWidth(FractionColumnWidth(0.2), IntrinsicColumnWidth()), + }, + border: TableBorder.all(width: 0, color: Colors.transparent), + children: children, + ); + } + + Widget _buildPaymentSection(TextTheme theme, Color text) { + return Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + DefaultTextStyle( + style: theme.bodyMedium!.copyWith(fontSize: theme.labelMedium!.fontSize, color: text), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(S.printerReceiptPaid), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(S.printerReceiptPrice), + Text(S.printerReceiptChange), + ]), + ), + ]), + ), + DefaultTextStyle( + style: theme.labelMedium!.copyWith(color: text), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('\$${order.paid.toCurrency()}'), + Text('\$${order.price.toCurrency()}'), + Text('\$${order.change.toCurrency()}'), + ], + ), + ), + ]); + } +} + +// Keep the old hardcoded version for backwards compatibility +class _OldPrinterReceiptView extends StatelessWidget { + final OrderObject order; + final ImageableController controller; + + const _OldPrinterReceiptView({ + required this.order, + required this.controller, }); @override diff --git a/lib/ui/printer/widgets/receipt_component_editor_dialog.dart b/lib/ui/printer/widgets/receipt_component_editor_dialog.dart new file mode 100644 index 00000000..ea78edbc --- /dev/null +++ b/lib/ui/printer/widgets/receipt_component_editor_dialog.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:possystem/components/dialog/responsive_dialog.dart'; +import 'package:possystem/models/receipt_component.dart'; +import 'package:possystem/translator.dart'; + +class ReceiptComponentEditorDialog extends StatefulWidget { + final ReceiptComponent component; + + const ReceiptComponentEditorDialog({ + super.key, + required this.component, + }); + + @override + State createState() => _ReceiptComponentEditorDialogState(); +} + +class _ReceiptComponentEditorDialogState extends State { + late ReceiptComponent _component; + + @override + void initState() { + super.initState(); + _component = widget.component.copyWith(); + } + + @override + Widget build(BuildContext context) { + return ResponsiveDialog( + title: Text(_getTitle()), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildEditor(), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => Navigator.of(context).pop(_component), + child: Text(MaterialLocalizations.of(context).saveButtonLabel), + ), + ], + ), + ], + ), + ); + } + + String _getTitle() { + switch (_component.type) { + case ReceiptComponentType.orderTable: + return S.printerReceiptComponentOrderTable; + case ReceiptComponentType.textField: + return S.printerReceiptComponentTextField; + case ReceiptComponentType.divider: + return S.printerReceiptComponentDivider; + case ReceiptComponentType.orderTimestamp: + return S.printerReceiptComponentTimestamp; + case ReceiptComponentType.orderId: + return S.printerReceiptComponentOrderId; + case ReceiptComponentType.totalSection: + return S.printerReceiptComponentTotalSection; + case ReceiptComponentType.paymentSection: + return S.printerReceiptComponentPaymentSection; + } + } + + Widget _buildEditor() { + switch (_component.type) { + case ReceiptComponentType.orderTable: + return _buildOrderTableEditor(); + case ReceiptComponentType.textField: + return _buildTextFieldEditor(); + case ReceiptComponentType.divider: + return _buildDividerEditor(); + case ReceiptComponentType.orderTimestamp: + return _buildTimestampEditor(); + case ReceiptComponentType.orderId: + return _buildOrderIdEditor(); + case ReceiptComponentType.totalSection: + return _buildTotalSectionEditor(); + case ReceiptComponentType.paymentSection: + return _buildPaymentSectionEditor(); + } + } + + Widget _buildOrderTableEditor() { + final c = _component as OrderTableComponent; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CheckboxListTile( + title: Text(S.printerReceiptComponentShowProductName), + value: c.showProductName, + onChanged: (value) { + setState(() { + _component = c.copyWith(showProductName: value); + }); + }, + ), + CheckboxListTile( + title: Text(S.printerReceiptComponentShowCatalogName), + value: c.showCatalogName, + onChanged: (value) { + setState(() { + _component = c.copyWith(showCatalogName: value); + }); + }, + ), + CheckboxListTile( + title: Text(S.printerReceiptColumnCount), + value: c.showCount, + onChanged: (value) { + setState(() { + _component = c.copyWith(showCount: value); + }); + }, + ), + CheckboxListTile( + title: Text(S.printerReceiptColumnPrice), + value: c.showPrice, + onChanged: (value) { + setState(() { + _component = c.copyWith(showPrice: value); + }); + }, + ), + CheckboxListTile( + title: Text(S.printerReceiptColumnTotal), + value: c.showTotal, + onChanged: (value) { + setState(() { + _component = c.copyWith(showTotal: value); + }); + }, + ), + ], + ); + } + + Widget _buildTextFieldEditor() { + final c = _component as TextFieldComponent; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + initialValue: c.text, + decoration: InputDecoration(labelText: S.printerReceiptComponentText), + maxLines: 3, + onChanged: (value) { + _component = c.copyWith(text: value); + }, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: Text(S.printerReceiptComponentFontSize)), + Text('${c.fontSize.toInt()}'), + ], + ), + Slider( + value: c.fontSize, + min: 8, + max: 32, + divisions: 24, + onChanged: (value) { + setState(() { + _component = c.copyWith(fontSize: value); + }); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: c.textAlign, + decoration: InputDecoration(labelText: S.printerReceiptComponentAlignment), + items: [ + DropdownMenuItem(value: TextAlign.left, child: Text(S.printerReceiptComponentAlignLeft)), + DropdownMenuItem(value: TextAlign.center, child: Text(S.printerReceiptComponentAlignCenter)), + DropdownMenuItem(value: TextAlign.right, child: Text(S.printerReceiptComponentAlignRight)), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _component = c.copyWith(textAlign: value); + }); + } + }, + ), + ], + ); + } + + Widget _buildDividerEditor() { + final c = _component as DividerComponent; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded(child: Text(S.printerReceiptComponentHeight)), + Text('${c.height}'), + ], + ), + Slider( + value: c.height, + min: 1, + max: 20, + divisions: 19, + onChanged: (value) { + setState(() { + _component = c.copyWith(height: value); + }); + }, + ), + ], + ); + } + + Widget _buildTimestampEditor() { + final c = _component as OrderTimestampComponent; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + value: c.dateFormat, + decoration: InputDecoration(labelText: S.printerReceiptComponentDateFormat), + items: [ + DropdownMenuItem(value: 'yMMMd Hms', child: Text(S.printerReceiptComponentDateFormatFull)), + DropdownMenuItem(value: 'yMMMd', child: Text(S.printerReceiptComponentDateFormatDate)), + DropdownMenuItem(value: 'Hms', child: Text(S.printerReceiptComponentDateFormatTime)), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _component = c.copyWith(dateFormat: value); + }); + } + }, + ), + ], + ); + } + + Widget _buildOrderIdEditor() { + final c = _component as OrderIdComponent; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded(child: Text(S.printerReceiptComponentFontSize)), + Text('${c.fontSize.toInt()}'), + ], + ), + Slider( + value: c.fontSize, + min: 8, + max: 32, + divisions: 24, + onChanged: (value) { + setState(() { + _component = c.copyWith(fontSize: value); + }); + }, + ), + ], + ); + } + + Widget _buildTotalSectionEditor() { + final c = _component as TotalSectionComponent; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CheckboxListTile( + title: Text(S.printerReceiptDiscountLabel), + value: c.showDiscounts, + onChanged: (value) { + setState(() { + _component = c.copyWith(showDiscounts: value); + }); + }, + ), + CheckboxListTile( + title: Text(S.printerReceiptAddOnsLabel), + value: c.showAddOns, + onChanged: (value) { + setState(() { + _component = c.copyWith(showAddOns: value); + }); + }, + ), + ], + ); + } + + Widget _buildPaymentSectionEditor() { + return Text(S.printerReceiptComponentPaymentDesc); + } +} diff --git a/lib/ui/printer/widgets/receipt_template_component_editor_page.dart b/lib/ui/printer/widgets/receipt_template_component_editor_page.dart new file mode 100644 index 00000000..bc9ba1d0 --- /dev/null +++ b/lib/ui/printer/widgets/receipt_template_component_editor_page.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:possystem/models/objects/receipt_template_object.dart'; +import 'package:possystem/models/receipt_component.dart'; +import 'package:possystem/models/repository/receipt_template.dart'; +import 'package:possystem/models/repository/receipt_templates.dart'; +import 'package:possystem/translator.dart'; +import 'package:possystem/ui/printer/widgets/receipt_component_editor_dialog.dart'; + +class ReceiptTemplateComponentEditorPage extends StatefulWidget { + final ReceiptTemplate template; + + const ReceiptTemplateComponentEditorPage({ + super.key, + required this.template, + }); + + @override + State createState() => _ReceiptTemplateComponentEditorPageState(); +} + +class _ReceiptTemplateComponentEditorPageState extends State { + late List components; + + @override + void initState() { + super.initState(); + components = List.from(widget.template.components); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.template.name), + actions: [ + TextButton( + onPressed: _resetToDefault, + child: Text(S.printerReceiptEditorReset), + ), + const SizedBox(width: 8), + ], + ), + body: ReorderableListView.builder( + itemCount: components.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = components.removeAt(oldIndex); + components.insert(newIndex, item); + }); + _saveComponents(); + }, + itemBuilder: (context, index) { + final component = components[index]; + return _ComponentTile( + key: ValueKey(component.id), + component: component, + onEdit: () => _editComponent(component), + onDelete: () => _deleteComponent(component), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _addComponent, + child: const Icon(Icons.add), + ), + ); + } + + void _addComponent() { + showDialog( + context: context, + builder: (context) => _ComponentTypeDialog( + onSelected: (type) { + setState(() { + components.add(_createDefaultComponent(type)); + }); + _saveComponents(); + }, + ), + ); + } + + void _editComponent(ReceiptComponent component) async { + final result = await showDialog( + context: context, + builder: (context) => ReceiptComponentEditorDialog(component: component), + ); + if (result != null) { + setState(() { + final index = components.indexWhere((c) => c.id == component.id); + if (index != -1) { + components[index] = result; + } + }); + _saveComponents(); + } + } + + void _deleteComponent(ReceiptComponent component) { + setState(() { + components.removeWhere((c) => c.id == component.id); + }); + _saveComponents(); + } + + void _resetToDefault() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(S.printerReceiptEditorResetTitle), + content: Text(S.printerReceiptEditorResetContent), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + FilledButton( + onPressed: () { + setState(() { + components = ReceiptTemplate._getDefaultComponents(); + }); + _saveComponents(); + Navigator.of(context).pop(); + }, + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ), + ); + } + + void _saveComponents() { + widget.template.update(ReceiptTemplateObject( + components: components, + // Keep other values + name: widget.template.name, + isDefault: widget.template.isDefault, + )); + } + + ReceiptComponent _createDefaultComponent(ReceiptComponentType type) { + final id = '${type.name}_${DateTime.now().millisecondsSinceEpoch}'; + switch (type) { + case ReceiptComponentType.orderTable: + return OrderTableComponent(id: id); + case ReceiptComponentType.textField: + return TextFieldComponent(id: id, text: 'Custom Text'); + case ReceiptComponentType.divider: + return DividerComponent(id: id); + case ReceiptComponentType.orderTimestamp: + return OrderTimestampComponent(id: id); + case ReceiptComponentType.orderId: + return OrderIdComponent(id: id); + case ReceiptComponentType.totalSection: + return TotalSectionComponent(id: id); + case ReceiptComponentType.paymentSection: + return PaymentSectionComponent(id: id); + } + } +} + +class _ComponentTile extends StatelessWidget { + final ReceiptComponent component; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _ComponentTile({ + super.key, + required this.component, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(_getIcon(component.type)), + title: Text(_getTitle(component)), + subtitle: Text(_getSubtitle(component)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: onEdit, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: onDelete, + ), + const Icon(Icons.drag_handle), + ], + ), + ); + } + + IconData _getIcon(ReceiptComponentType type) { + switch (type) { + case ReceiptComponentType.orderTable: + return Icons.table_chart; + case ReceiptComponentType.textField: + return Icons.text_fields; + case ReceiptComponentType.divider: + return Icons.horizontal_rule; + case ReceiptComponentType.orderTimestamp: + return Icons.access_time; + case ReceiptComponentType.orderId: + return Icons.tag; + case ReceiptComponentType.totalSection: + return Icons.calculate; + case ReceiptComponentType.paymentSection: + return Icons.payment; + } + } + + String _getTitle(ReceiptComponent component) { + switch (component.type) { + case ReceiptComponentType.orderTable: + return S.printerReceiptComponentOrderTable; + case ReceiptComponentType.textField: + final c = component as TextFieldComponent; + return c.text.isEmpty ? S.printerReceiptComponentTextField : c.text; + case ReceiptComponentType.divider: + return S.printerReceiptComponentDivider; + case ReceiptComponentType.orderTimestamp: + return S.printerReceiptComponentTimestamp; + case ReceiptComponentType.orderId: + return S.printerReceiptComponentOrderId; + case ReceiptComponentType.totalSection: + return S.printerReceiptComponentTotalSection; + case ReceiptComponentType.paymentSection: + return S.printerReceiptComponentPaymentSection; + } + } + + String _getSubtitle(ReceiptComponent component) { + switch (component.type) { + case ReceiptComponentType.orderTable: + final c = component as OrderTableComponent; + final columns = []; + if (c.showProductName) columns.add(S.printerReceiptColumnName); + if (c.showCount) columns.add(S.printerReceiptColumnCount); + if (c.showPrice) columns.add(S.printerReceiptColumnPrice); + if (c.showTotal) columns.add(S.printerReceiptColumnTotal); + return columns.join(', '); + case ReceiptComponentType.textField: + final c = component as TextFieldComponent; + return '${S.printerReceiptComponentFontSize}: ${c.fontSize.toInt()}'; + case ReceiptComponentType.divider: + final c = component as DividerComponent; + return '${S.printerReceiptComponentHeight}: ${c.height}'; + case ReceiptComponentType.orderTimestamp: + final c = component as OrderTimestampComponent; + return c.dateFormat; + case ReceiptComponentType.orderId: + return S.printerReceiptComponentOrderIdDesc; + case ReceiptComponentType.totalSection: + final c = component as TotalSectionComponent; + final parts = []; + if (c.showDiscounts) parts.add(S.printerReceiptDiscountLabel); + if (c.showAddOns) parts.add(S.printerReceiptAddOnsLabel); + return parts.isEmpty ? S.printerReceiptTotal : parts.join(', '); + case ReceiptComponentType.paymentSection: + return S.printerReceiptComponentPaymentDesc; + } + } +} + +class _ComponentTypeDialog extends StatelessWidget { + final Function(ReceiptComponentType) onSelected; + + const _ComponentTypeDialog({required this.onSelected}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(S.printerReceiptComponentAddTitle), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: ReceiptComponentType.values.map((type) { + return ListTile( + leading: Icon(_getIcon(type)), + title: Text(_getTitle(type)), + onTap: () { + onSelected(type); + Navigator.of(context).pop(); + }, + ); + }).toList(), + ), + ), + ); + } + + IconData _getIcon(ReceiptComponentType type) { + switch (type) { + case ReceiptComponentType.orderTable: + return Icons.table_chart; + case ReceiptComponentType.textField: + return Icons.text_fields; + case ReceiptComponentType.divider: + return Icons.horizontal_rule; + case ReceiptComponentType.orderTimestamp: + return Icons.access_time; + case ReceiptComponentType.orderId: + return Icons.tag; + case ReceiptComponentType.totalSection: + return Icons.calculate; + case ReceiptComponentType.paymentSection: + return Icons.payment; + } + } + + String _getTitle(ReceiptComponentType type) { + switch (type) { + case ReceiptComponentType.orderTable: + return S.printerReceiptComponentOrderTable; + case ReceiptComponentType.textField: + return S.printerReceiptComponentTextField; + case ReceiptComponentType.divider: + return S.printerReceiptComponentDivider; + case ReceiptComponentType.orderTimestamp: + return S.printerReceiptComponentTimestamp; + case ReceiptComponentType.orderId: + return S.printerReceiptComponentOrderId; + case ReceiptComponentType.totalSection: + return S.printerReceiptComponentTotalSection; + case ReceiptComponentType.paymentSection: + return S.printerReceiptComponentPaymentSection; + } + } +} diff --git a/lib/ui/printer/widgets/receipt_template_modal.dart b/lib/ui/printer/widgets/receipt_template_modal.dart new file mode 100644 index 00000000..ab3277cf --- /dev/null +++ b/lib/ui/printer/widgets/receipt_template_modal.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:possystem/components/dialog/confirm_dialog.dart'; +import 'package:possystem/components/scaffold/item_modal.dart'; +import 'package:possystem/helpers/validator.dart'; +import 'package:possystem/models/objects/receipt_template_object.dart'; +import 'package:possystem/models/repository/receipt_template.dart'; +import 'package:possystem/models/repository/receipt_templates.dart'; +import 'package:possystem/routes.dart'; +import 'package:possystem/translator.dart'; + +class ReceiptTemplateModal extends StatefulWidget { + final ReceiptTemplate? template; + + final bool isNew; + + const ReceiptTemplateModal({ + super.key, + this.template, + }) : isNew = template == null; + + @override + State createState() => _ReceiptTemplateModalState(); +} + +class _ReceiptTemplateModalState extends State with ItemModal { + late TextEditingController _nameController; + late FocusNode _nameFocusNode; + late bool isDefault; + + @override + String get title => widget.isNew ? S.printerReceiptTemplateTitleCreate : S.printerReceiptTemplateTitleUpdate; + + @override + List buildFormFields() { + return [ + TextFormField( + key: const Key('receipt_template.name'), + controller: _nameController, + textInputAction: TextInputAction.done, + textCapitalization: TextCapitalization.words, + focusNode: _nameFocusNode, + decoration: InputDecoration( + labelText: S.printerReceiptTemplateNameLabel, + hintText: widget.template?.name, + filled: false, + ), + maxLength: 30, + validator: Validator.textLimit( + S.printerReceiptTemplateNameLabel, + 30, + focusNode: _nameFocusNode, + validator: (name) { + return widget.template?.name != name && ReceiptTemplates.instance.hasName(name) + ? S.printerReceiptTemplateNameErrorRepeat + : null; + }, + ), + onFieldSubmitted: handleFieldSubmit, + ), + CheckboxListTile( + key: const Key('receipt_template.isDefault'), + controlAffinity: ListTileControlAffinity.leading, + value: isDefault, + selected: isDefault, + onChanged: _toggledDefault, + title: Text(S.printerReceiptTemplateToDefaultLabel), + subtitle: Text(S.printerReceiptTemplateToDefaultHelper), + ), + if (!widget.isNew) + ListTile( + leading: const Icon(Icons.edit), + title: Text(S.printerReceiptTemplateEditComponents), + subtitle: Text(S.printerReceiptComponentCount(widget.template!.components.length)), + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.pushNamed( + Routes.printerReceiptTemplateComponentEditor, + pathParameters: {'id': widget.template!.id}, + ); + }, + ), + ]; + } + + @override + void initState() { + _nameController = TextEditingController(text: widget.template?.name); + _nameFocusNode = FocusNode(); + + isDefault = widget.template?.isDefault ?? false; + + super.initState(); + } + + @override + void dispose() { + _nameController.dispose(); + _nameFocusNode.dispose(); + super.dispose(); + } + + @override + Future updateItem() async { + final object = ReceiptTemplateObject( + name: _nameController.text, + isDefault: isDefault, + components: widget.template?.components, + ); + + // if turn to default or add default + if (isDefault && widget.template?.isDefault != true) { + await ReceiptTemplates.instance.clearDefault(); + } + + if (widget.isNew) { + await ReceiptTemplates.instance.addItem(ReceiptTemplate( + name: object.name!, + isDefault: isDefault, + components: ReceiptTemplate._getDefaultComponents(), + )); + } else { + await widget.template!.update(object); + } + + if (mounted && context.canPop()) { + context.pop(); + } + } + + void _toggledDefault(bool? value) async { + final defaultTemplate = ReceiptTemplates.instance.defaultTemplate; + // warn if default template is going to be changed + if (value == true && defaultTemplate != null && defaultTemplate.id != widget.template?.id) { + final confirmed = await ConfirmDialog.show( + context, + title: S.printerReceiptTemplateToDefaultConfirmChangeTitle, + content: S.printerReceiptTemplateToDefaultConfirmChangeContent(defaultTemplate.name), + ); + + if (confirmed) { + setState(() => isDefault = value!); + } + } else { + setState(() => isDefault = value!); + } + } +}