first commit

This commit is contained in:
2026-02-07 15:57:09 +07:00
commit 157096f164
1153 changed files with 415766 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
library;
export 'src/templates/templates.dart';

View File

@@ -0,0 +1,304 @@
import 'dart:typed_data';
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:image/image.dart' as img;
import 'package:mobile_pos/Const/api_config.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import 'package:mobile_pos/thermal%20priting%20invoices/model/print_transaction_model.dart';
import '../../thermer/thermer.dart' as thermer;
class DueThermalInvoiceTemplate {
DueThermalInvoiceTemplate({
required this.printDueTransactionModel,
required this.is58mm,
required this.context,
required this.isRTL,
});
final PrintDueTransactionModel printDueTransactionModel;
final bool is58mm;
final BuildContext context;
final bool isRTL;
// --- Helpers: Styles & Formats ---
thermer.TextStyle _commonStyle({double fontSize = 24, bool isBold = false}) {
return thermer.TextStyle(
fontSize: fontSize,
fontWeight: isBold ? thermer.FontWeight.bold : thermer.FontWeight.w500,
color: thermer.Colors.black,
);
}
String formatPointNumber(num number, {bool addComma = false}) {
if (addComma) return NumberFormat("#,###.##", "en_US").format(number);
return number.toStringAsFixed(2);
}
// --- Main Generator ---
Future<List<int>> get template async {
final _lang = lang.S.of(context);
final _profile = await CapabilityProfile.load();
final _generator = Generator(is58mm ? PaperSize.mm58 : PaperSize.mm80, _profile);
final _imageBytes = await _generateLayout(_lang);
final _image = img.decodeImage(_imageBytes);
if (_image == null) throw Exception('Failed to generate receipt.');
List<int> _bytes = [];
_bytes += _generator.image(_image);
_bytes += _generator.cut();
return _bytes;
}
Future<Uint8List> _generateLayout(lang.S _lang) async {
final data = printDueTransactionModel.dueTransactionModel;
final info = printDueTransactionModel.personalInformationModel.data;
// 1. Prepare Logo
thermer.ThermerImage? _logo;
if (info?.thermalInvoiceLogo != null && info?.showThermalInvoiceLogo == 1) {
try {
_logo = await thermer.ThermerImage.network(
"${APIConfig.domain}${info?.thermalInvoiceLogo}",
width: is58mm ? 120 : 184,
height: is58mm ? 120 : 184,
);
} catch (_) {}
}
//qr logo
thermer.ThermerImage? _qrLogo;
if (info?.invoiceScannerLogo != null && info?.showInvoiceScannerLogo == 1) {
try {
_qrLogo = await thermer.ThermerImage.network(
APIConfig.domain + info!.invoiceScannerLogo!,
width: is58mm ? 120 : 140,
height: is58mm ? 120 : 140,
);
} catch (_) {}
}
// 2. Prepare Payment Labels
final paymentLabels = _buildPaymentLabels();
// 3. Build Layout
final _layout = thermer.ThermerLayout(
paperSize: is58mm ? thermer.PaperSize.mm58 : thermer.PaperSize.mm80,
textDirection: isRTL ? thermer.TextDirection.rtl : thermer.TextDirection.ltr,
widgets: [
// --- Header ---
if (_logo != null) ...[thermer.ThermerAlign(child: _logo), thermer.ThermerSizedBox(height: 16)],
if (info?.meta?.showCompanyName == 1)
thermer.ThermerText(
info?.companyName ?? '',
style: _commonStyle(fontSize: is58mm ? 46 : 54),
textAlign: thermer.TextAlign.center,
),
if (data?.branch?.name != null)
thermer.ThermerText('Branch: ${data?.branch?.name}',
style: _commonStyle(), textAlign: thermer.TextAlign.center),
if (info?.meta?.showAddress == 1)
if (data?.branch?.address != null || info?.address != null)
thermer.ThermerText(
'${_lang.address}: ${data?.branch?.address ?? info?.address ?? ''}',
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
if (info?.meta?.showPhoneNumber == 1)
if (data?.branch?.phone != null || info?.phoneNumber != null)
thermer.ThermerText(
'${_lang.mobile} ${data?.branch?.phone ?? info?.phoneNumber ?? ''}',
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
if (info?.meta?.showVat == 1)
if (info?.vatNo != null && info?.meta?.showVat == 1)
thermer.ThermerText(
"${info?.vatName ?? _lang.vatNumber}: ${info?.vatNo}",
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
thermer.ThermerSizedBox(height: 16),
thermer.ThermerText(
_lang.receipt, // Due collection is usually a Receipt
style: _commonStyle(fontSize: is58mm ? 30 : 48, isBold: true)
.copyWith(decoration: thermer.TextDecoration.underline),
textAlign: thermer.TextAlign.center,
),
thermer.ThermerSizedBox(height: 16),
// --- Info Section ---
..._buildInfoSection(_lang),
thermer.ThermerSizedBox(height: 16),
// --- Data Table (Single Row for Due Context) ---
thermer.ThermerTable(
header: thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText(_lang.sl, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.invoice, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.dueAmount, textAlign: thermer.TextAlign.end, style: _commonStyle(isBold: true)),
]),
data: [
thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText('1', style: _commonStyle()),
thermer.ThermerText(data?.invoiceNumber ?? '', style: _commonStyle()),
thermer.ThermerText(formatPointNumber(data?.totalDue ?? 0, addComma: true),
textAlign: thermer.TextAlign.end, style: _commonStyle()),
])
],
cellWidths: is58mm ? {0: null, 1: 0.3} : {0: 0.1, 1: null, 2: 0.3},
),
thermer.ThermerDivider.horizontal(),
// --- Calculations ---
_buildCalculationColumn(_lang),
thermer.ThermerDivider.horizontal(),
thermer.ThermerSizedBox(height: 8),
// --- Payment Info ---
thermer.ThermerText(
"${_lang.paidVia} : ${paymentLabels.join(', ')}",
style: _commonStyle(),
textAlign: thermer.TextAlign.left,
),
thermer.ThermerSizedBox(height: 16),
// --- Footer ---
if (info?.gratitudeMessage != null && info?.showGratitudeMsg == 1)
thermer.ThermerText(info?.gratitudeMessage ?? '',
textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
if (data?.paymentDate != null)
thermer.ThermerText(
DateFormat('M/d/yyyy h:mm a').format(DateTime.parse(data!.paymentDate!)),
textAlign: thermer.TextAlign.center,
style: _commonStyle(),
),
if (info?.showNote == 1)
thermer.ThermerText(
'${info?.invoiceNoteLevel ?? _lang.note}: ${info?.invoiceNote ?? ''}',
textAlign: thermer.TextAlign.left,
style: _commonStyle(),
),
thermer.ThermerSizedBox(height: 16),
if (_qrLogo != null) ...[thermer.ThermerAlign(child: _qrLogo), thermer.ThermerSizedBox(height: 1)],
if (info?.developBy != null)
thermer.ThermerText(
'${info?.developByLevel ?? _lang.developedBy}: ${info?.developBy}',
textAlign: thermer.TextAlign.center,
style: _commonStyle(),
),
thermer.ThermerSizedBox(height: 200), // Cutter Space
],
);
return _layout.toUint8List();
}
// --- Sub-Builders ---
List<thermer.ThermerWidget> _buildInfoSection(lang.S _lang) {
final data = printDueTransactionModel.dueTransactionModel;
final dateStr = data?.paymentDate != null ? DateFormat.yMd().format(DateTime.parse(data!.paymentDate!)) : '';
final timeStr = data?.paymentDate != null ? DateFormat.jm().format(DateTime.parse(data!.paymentDate!)) : '';
final receiptText = '${_lang.receipt}: ${data?.invoiceNumber ?? 'Not Provided'}';
final dateText = '${_lang.date}: $dateStr';
final timeText = '${_lang.time}: $timeStr';
final nameText = '${_lang.receivedFrom}: ${data?.party?.name ?? ''}';
final mobileText = '${_lang.mobile} ${data?.party?.phone ?? ''}';
final receivedByText =
'${_lang.receivedBy}: ${data?.user?.role == "shop-owner" ? 'Admin' : data?.user?.name ?? ''}';
if (is58mm) {
// 58mm: Stacked
return [
thermer.ThermerText(receiptText, style: _commonStyle()),
if (data?.paymentDate != null) thermer.ThermerText("$dateText $timeStr", style: _commonStyle()),
thermer.ThermerText(nameText, style: _commonStyle()),
thermer.ThermerText(mobileText, style: _commonStyle()),
thermer.ThermerText(receivedByText, style: _commonStyle()),
];
} else {
// 80mm: Two Columns
return [
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(receiptText, style: _commonStyle()),
if (data?.paymentDate != null) thermer.ThermerText(dateText, style: _commonStyle()),
],
),
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(nameText, style: _commonStyle()),
thermer.ThermerText(timeText, style: _commonStyle()),
],
),
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(mobileText, style: _commonStyle()),
thermer.ThermerText(receivedByText, style: _commonStyle()),
],
),
];
}
}
thermer.ThermerColumn _buildCalculationColumn(lang.S _lang) {
final data = printDueTransactionModel.dueTransactionModel;
thermer.ThermerRow calcRow(String label, num value, {bool bold = false, bool isCurrency = true}) {
return thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(label, style: _commonStyle(isBold: bold)),
thermer.ThermerText(
isCurrency ? formatPointNumber(value, addComma: true) : value.toString(),
textAlign: thermer.TextAlign.end,
style: _commonStyle(isBold: bold),
),
],
);
}
return thermer.ThermerColumn(children: [
calcRow('${_lang.totalDue}:', data?.totalDue ?? 0),
calcRow('${_lang.paymentsAmount}:', data?.payDueAmount ?? 0, bold: true),
calcRow('${_lang.remainingDue}:', data?.dueAmountAfterPay ?? 0, bold: true),
]);
}
List<String> _buildPaymentLabels() {
final transactions = printDueTransactionModel.dueTransactionModel?.transactions ?? [];
List<String> labels = [];
for (var item in transactions) {
String label = item.paymentType?.name ?? 'n/a';
if (item.transactionType == 'cash_payment') label = lang.S.of(context).cash;
if (item.transactionType == 'cheque_payment') label = lang.S.of(context).cheque;
if (item.transactionType == 'wallet_payment') label = lang.S.of(context).wallet;
labels.add(label);
}
return labels;
}
}

View File

@@ -0,0 +1,476 @@
import 'dart:typed_data';
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:intl/intl.dart';
import 'package:mobile_pos/Const/api_config.dart';
import 'package:mobile_pos/Screens/Products/add%20product/modle/create_product_model.dart';
import 'package:mobile_pos/Screens/Purchase/Model/purchase_transaction_model.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import 'package:mobile_pos/thermal%20priting%20invoices/model/print_transaction_model.dart';
import '../../thermer/thermer.dart' as thermer;
class PurchaseThermalInvoiceTemplate {
PurchaseThermalInvoiceTemplate({
required this.printTransactionModel,
required this.productList,
required this.is58mm,
required this.context,
required this.isRTL,
});
final PrintPurchaseTransactionModel printTransactionModel;
final List<PurchaseDetails>? productList;
final bool is58mm;
final BuildContext context;
final bool isRTL;
// --- Helpers: Styles & Formats ---
thermer.TextStyle _commonStyle({double fontSize = 24, bool isBold = false}) {
return thermer.TextStyle(
fontSize: fontSize,
fontWeight: isBold ? thermer.FontWeight.bold : thermer.FontWeight.w500,
color: thermer.Colors.black,
);
}
String formatPointNumber(num number, {bool addComma = false}) {
if (addComma) return NumberFormat("#,###.##", "en_US").format(number);
return number.toStringAsFixed(2);
}
// --- Data Logic (Adapted from your provided code) ---
num _getProductPrice(num detailsId) {
return productList!.where((element) => element.id == detailsId).first.productPurchasePrice ?? 0;
}
String _getProductName(num detailsId) {
final details = printTransactionModel.purchaseTransitionModel?.details?.firstWhere(
(element) => element.id == detailsId,
orElse: () => PurchaseDetails(),
);
String name = details?.product?.productName ?? '';
if (details?.product?.productType == ProductType.variant.name) {
name += ' [${details?.stock?.batchNo ?? ''}]';
}
return name;
}
num _getProductQuantity(num detailsId) {
num totalQuantity = productList?.where((element) => element.id == detailsId).first.quantities ?? 0;
// Add returned quantities logic
if (printTransactionModel.purchaseTransitionModel?.purchaseReturns?.isNotEmpty ?? false) {
for (var returns in printTransactionModel.purchaseTransitionModel!.purchaseReturns!) {
if (returns.purchaseReturnDetails?.isNotEmpty ?? false) {
for (var details in returns.purchaseReturnDetails!) {
if (details.purchaseDetailId == detailsId) {
totalQuantity += details.returnQty ?? 0;
}
}
}
}
}
return totalQuantity;
}
num _getTotalForOldInvoice() {
num total = 0;
if (productList != null) {
for (var element in productList!) {
num productPrice = element.productPurchasePrice ?? 0;
num productQuantity = _getProductQuantity(element.id ?? 0);
total += productPrice * productQuantity;
}
}
return total;
}
num _getReturnedDiscountAmount() {
num totalReturnDiscount = 0;
if (printTransactionModel.purchaseTransitionModel?.purchaseReturns?.isNotEmpty ?? false) {
for (var returns in printTransactionModel.purchaseTransitionModel!.purchaseReturns!) {
if (returns.purchaseReturnDetails?.isNotEmpty ?? false) {
for (var details in returns.purchaseReturnDetails!) {
totalReturnDiscount += ((_getProductPrice(details.purchaseDetailId ?? 0) * (details.returnQty ?? 0)) -
((details.returnAmount ?? 0)));
}
}
}
}
return totalReturnDiscount;
}
num _getTotalReturnedAmount() {
num totalReturn = 0;
if (printTransactionModel.purchaseTransitionModel?.purchaseReturns?.isNotEmpty ?? false) {
for (var returns in printTransactionModel.purchaseTransitionModel!.purchaseReturns!) {
if (returns.purchaseReturnDetails?.isNotEmpty ?? false) {
for (var details in returns.purchaseReturnDetails!) {
totalReturn += details.returnAmount ?? 0;
}
}
}
}
return totalReturn;
}
// --- Main Generator ---
Future<List<int>> get template async {
final _profile = await CapabilityProfile.load();
final _generator = Generator(is58mm ? PaperSize.mm58 : PaperSize.mm80, _profile);
final _imageBytes = await _generateLayout();
final _image = img.decodeImage(_imageBytes);
if (_image == null) throw Exception('Failed to generate invoice.');
List<int> _bytes = [];
_bytes += _generator.image(_image);
_bytes += _generator.cut();
return _bytes;
}
Future<Uint8List> _generateLayout() async {
final data = printTransactionModel.purchaseTransitionModel;
final info = printTransactionModel.personalInformationModel.data;
final _lang = lang.S.of(context);
// 1. Prepare Logo
thermer.ThermerImage? _logo;
if (info?.thermalInvoiceLogo != null && info?.showThermalInvoiceLogo == 1) {
try {
_logo = await thermer.ThermerImage.network(
"${APIConfig.domain}${info?.thermalInvoiceLogo}",
width: is58mm ? 120 : 184,
height: is58mm ? 120 : 184,
);
} catch (_) {}
}
//qr logo
thermer.ThermerImage? _qrLogo;
if (info?.invoiceScannerLogo != null && info?.showInvoiceScannerLogo == 1) {
try {
_qrLogo = await thermer.ThermerImage.network(
APIConfig.domain + info!.invoiceScannerLogo!,
width: is58mm ? 120 : 140,
height: is58mm ? 120 : 140,
);
} catch (_) {}
}
// 2. Prepare Product Rows
final productRows = _buildProductRows();
// 3. Prepare Returns
final returnWidgets = _buildReturnSection(_lang);
// 4. Build Layout
final _layout = thermer.ThermerLayout(
paperSize: is58mm ? thermer.PaperSize.mm58 : thermer.PaperSize.mm80,
textDirection: isRTL ? thermer.TextDirection.rtl : thermer.TextDirection.ltr,
widgets: [
// --- Header ---
if (_logo != null) ...[thermer.ThermerAlign(child: _logo), thermer.ThermerSizedBox(height: 16)],
if (info?.meta?.showCompanyName == 1)
thermer.ThermerText(
info?.companyName ?? '',
style: _commonStyle(fontSize: is58mm ? 46 : 54),
textAlign: thermer.TextAlign.center,
),
if (data?.branch?.name != null)
thermer.ThermerText('${_lang.branch}: ${data?.branch?.name}',
style: _commonStyle(), textAlign: thermer.TextAlign.center),
if (info?.meta?.showAddress == 1)
if (data?.branch?.address != null || info?.address != null)
thermer.ThermerText(
'${_lang.address}: ${data?.branch?.address ?? info?.address ?? ''}',
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
if (info?.meta?.showPhoneNumber == 1)
if (data?.branch?.phone != null || info?.phoneNumber != null)
thermer.ThermerText(
'${_lang.mobile} ${data?.branch?.phone ?? info?.phoneNumber ?? ''}',
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
if (info?.meta?.showVat == 1)
if (info?.vatNo != null && info?.meta?.showVat == 1)
thermer.ThermerText(
"${info?.vatName ?? _lang.vatNumber}: ${info?.vatNo}",
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
thermer.ThermerSizedBox(height: 16),
thermer.ThermerText(
_lang.invoice,
style: _commonStyle(fontSize: is58mm ? 30 : 48, isBold: true)
.copyWith(decoration: thermer.TextDecoration.underline),
textAlign: thermer.TextAlign.center,
),
thermer.ThermerSizedBox(height: 16),
// --- Info Section ---
..._buildInfoSection(_lang),
thermer.ThermerSizedBox(height: 8),
// --- Product Table ---
thermer.ThermerTable(
header: thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText(_lang.sl, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.item, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.qty, textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.price, textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.total, textAlign: thermer.TextAlign.end, style: _commonStyle(isBold: true)),
]),
data: productRows,
cellWidths: is58mm
? {0: null, 1: 0.2, 2: 0.2, 3: 0.25} // 58mm
: {0: 0.1, 1: null, 2: 0.15, 3: 0.15, 4: 0.2}, // 80mm
columnSpacing: 10.0,
rowSpacing: 3.0,
),
thermer.ThermerDivider.horizontal(),
// --- Calculations ---
if (!is58mm)
thermer.ThermerRow(
children: [
thermer.ThermerExpanded(flex: 4, child: thermer.ThermerAlign(child: _buildPaymentInfoText(_lang))),
thermer.ThermerExpanded(flex: 6, child: _buildCalculationColumn(_lang)),
],
)
else ...[
_buildCalculationColumn(_lang),
thermer.ThermerDivider.horizontal(),
_buildPaymentInfoText(_lang),
],
thermer.ThermerSizedBox(height: 16),
// --- Returns ---
...returnWidgets,
// --- Footer ---
if (info?.gratitudeMessage != null && info?.showGratitudeMsg == 1)
thermer.ThermerText(info?.gratitudeMessage ?? '',
textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
if (data?.purchaseDate != null)
thermer.ThermerText(
DateFormat('M/d/yyyy h:mm a').format(DateTime.parse(data!.purchaseDate!)),
textAlign: thermer.TextAlign.center,
style: _commonStyle(),
),
thermer.ThermerSizedBox(height: 16),
if (info?.showNote == 1)
thermer.ThermerText(
'${info?.invoiceNoteLevel ?? _lang.note}: ${info?.invoiceNote ?? ''}',
textAlign: thermer.TextAlign.left,
style: _commonStyle(),
),
thermer.ThermerSizedBox(height: 16),
if (_qrLogo != null) ...[thermer.ThermerAlign(child: _qrLogo), thermer.ThermerSizedBox(height: 1)],
// if (info?.developByLink != null)
// thermer.ThermerAlign(child: thermer.ThermerQRCode(data: info?.developByLink ?? '', size: 120)),
if (info?.developBy != null)
thermer.ThermerText(
'${info?.developByLevel ?? _lang.developedBy}: ${info?.developBy}',
textAlign: thermer.TextAlign.center,
style: _commonStyle(),
),
thermer.ThermerSizedBox(height: 200), // Cutter Space
],
);
return _layout.toUint8List();
}
// --- Sub-Builders ---
List<thermer.ThermerWidget> _buildInfoSection(lang.S _lang) {
final data = printTransactionModel.purchaseTransitionModel;
final dateStr = data?.purchaseDate != null ? DateFormat.yMd().format(DateTime.parse(data!.purchaseDate!)) : '';
final timeStr = data?.purchaseDate != null ? DateFormat.jm().format(DateTime.parse(data!.purchaseDate!)) : '';
final invText = '${_lang.invoice}: ${data?.invoiceNumber ?? 'Not Provided'}';
final dateText = '${_lang.date}: $dateStr';
final timeText = '${_lang.time}: $timeStr';
final nameText = '${_lang.name}: ${data?.party?.name ?? 'Guest'}';
final mobileText = '${_lang.mobile} ${data?.party?.phone ?? ''}';
final purchaseByText = '${_lang.purchaseBy} ${data?.user?.role == "shop-owner" ? 'Admin' : data?.user?.name ?? ''}';
if (is58mm) {
return [
thermer.ThermerText(invText, style: _commonStyle()),
if (data?.purchaseDate != null) thermer.ThermerText("$dateText $timeStr", style: _commonStyle()),
thermer.ThermerText(nameText, style: _commonStyle()),
thermer.ThermerText(mobileText, style: _commonStyle()),
thermer.ThermerText(purchaseByText, style: _commonStyle()),
];
} else {
return [
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(invText, style: _commonStyle()),
if (data?.purchaseDate != null) thermer.ThermerText(dateText, style: _commonStyle()),
],
),
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(nameText, style: _commonStyle()),
thermer.ThermerText(timeText, style: _commonStyle()),
],
),
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(mobileText, style: _commonStyle()),
thermer.ThermerText(purchaseByText, style: _commonStyle()),
],
),
];
}
}
List<thermer.ThermerTableRow> _buildProductRows() {
List<thermer.ThermerTableRow> rows = [];
if (productList == null) return rows;
for (var index = 0; index < productList!.length; index++) {
final item = productList![index];
final qty = _getProductQuantity(item.id ?? 0);
final price = item.productPurchasePrice ?? 0;
final amount = price * qty;
rows.add(thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText('${index + 1}', style: _commonStyle()),
thermer.ThermerText(_getProductName(item.id ?? 0), style: _commonStyle()),
thermer.ThermerText(formatPointNumber(qty, addComma: true),
textAlign: thermer.TextAlign.center, style: _commonStyle()),
thermer.ThermerText(formatPointNumber(price, addComma: true),
textAlign: thermer.TextAlign.center, style: _commonStyle()),
thermer.ThermerText(formatPointNumber(amount, addComma: true),
textAlign: thermer.TextAlign.end, style: _commonStyle()),
]));
}
return rows;
}
thermer.ThermerColumn _buildCalculationColumn(lang.S _lang) {
final data = printTransactionModel.purchaseTransitionModel;
thermer.ThermerRow calcRow(String label, num value, {bool bold = false, bool isCurrency = true}) {
return thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(label, style: _commonStyle(isBold: bold)),
thermer.ThermerText(
isCurrency ? formatPointNumber(value, addComma: true) : value.toString(),
textAlign: thermer.TextAlign.end,
style: _commonStyle(isBold: bold),
),
],
);
}
return thermer.ThermerColumn(children: [
calcRow('${_lang.subTotal}:', _getTotalForOldInvoice()),
calcRow('${_lang.discount}:', (data?.discountAmount ?? 0) + _getReturnedDiscountAmount()),
calcRow('${data?.vat?.name ?? _lang.vat}:', data?.vatAmount ?? 0),
thermer.ThermerDivider.horizontal(),
if (_getTotalReturnedAmount() > 0) calcRow('${_lang.returnAmount}:', _getTotalReturnedAmount()),
calcRow('${_lang.totalPayable}:', data?.totalAmount ?? 0, bold: true),
calcRow('${_lang.paidAmount}:', ((data?.totalAmount ?? 0) - (data?.dueAmount ?? 0)) + (data?.changeAmount ?? 0)),
if ((data?.dueAmount ?? 0) > 0) calcRow('${_lang.dueAmount}', data?.dueAmount ?? 0),
if ((data?.changeAmount ?? 0) > 0) calcRow('${_lang.changeAmount}:', data?.changeAmount ?? 0),
]);
}
thermer.ThermerText _buildPaymentInfoText(lang.S _lang) {
final transactions = printTransactionModel.purchaseTransitionModel?.transactions ?? [];
List<String> labels = [];
for (var item in transactions) {
String label = item.paymentType?.name ?? 'n/a';
if (item.transactionType == 'cash_payment') label = _lang.cash;
if (item.transactionType == 'cheque_payment') label = _lang.cheque;
if (item.transactionType == 'wallet_payment') label = _lang.wallet;
labels.add(label);
}
return thermer.ThermerText(
"${_lang.paidVia}: ${labels.join(', ')}",
style: _commonStyle(),
textAlign: is58mm ? thermer.TextAlign.left : thermer.TextAlign.left,
);
}
List<thermer.ThermerWidget> _buildReturnSection(lang.S _lang) {
final returns = printTransactionModel.purchaseTransitionModel?.purchaseReturns;
if (returns?.isEmpty ?? true) return [];
List<thermer.ThermerWidget> widgets = [];
List<String> processedDates = [];
for (var i = 0; i < (returns?.length ?? 0); i++) {
final dateStr = returns![i].returnDate?.substring(0, 10);
if (dateStr != null && !processedDates.contains(dateStr)) {
processedDates.add(dateStr);
widgets.add(thermer.ThermerDivider.horizontal());
// Return Header
widgets.add(thermer.ThermerRow(
children: [
if (!is58mm) thermer.ThermerText(_lang.sl, style: _commonStyle(isBold: true)),
thermer.ThermerText('${_lang.retur}-${DateFormat.yMd().format(DateTime.parse(returns[i].returnDate!))}',
style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.qty, textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.total, textAlign: thermer.TextAlign.end, style: _commonStyle(isBold: true)),
],
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
));
}
widgets.add(thermer.ThermerTable(
data: (returns[i].purchaseReturnDetails ?? []).map((d) {
// Re-using index logic might be tricky here for SL, simplify if needed
return thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText('*', style: _commonStyle()), // Bullet for return items or dynamic index
thermer.ThermerText(_getProductName(d.purchaseDetailId ?? 0), style: _commonStyle()),
thermer.ThermerText('${d.returnQty ?? 0}', textAlign: thermer.TextAlign.center, style: _commonStyle()),
thermer.ThermerText(formatPointNumber(d.returnAmount ?? 0, addComma: true),
textAlign: thermer.TextAlign.end, style: _commonStyle()),
]);
}).toList(),
cellWidths: is58mm ? {0: null, 1: 0.2, 2: 0.25} : {0: 0.1, 1: null, 2: 0.15, 3: 0.2},
));
}
// Add Total Return Footer inside Calculation Column generally,
// but if you want separate divider:
widgets.add(thermer.ThermerDivider.horizontal());
return widgets;
}
}

View File

@@ -0,0 +1,490 @@
import 'dart:typed_data';
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:intl/intl.dart';
import 'package:mobile_pos/Const/api_config.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import 'package:mobile_pos/model/business_info_model.dart';
import 'package:mobile_pos/model/sale_transaction_model.dart';
import '../../thermer/thermer.dart' as thermer;
class SaleThermalInvoiceTemplate {
SaleThermalInvoiceTemplate({
required this.saleInvoice,
required this.is58mm,
required this.business,
required this.context,
required this.isRTL,
});
final SalesTransactionModel saleInvoice;
final BusinessInformationModel business;
final bool is58mm;
final bool isRTL;
final BuildContext context;
// --- Helpers: Styles & Formats ---
/// Centralized Text Style to ensure Black Color everywhere
thermer.TextStyle _commonStyle({double fontSize = 24, bool isBold = false}) {
return thermer.TextStyle(
fontSize: fontSize,
fontWeight: isBold ? thermer.FontWeight.bold : thermer.FontWeight.w500,
color: thermer.Colors.black,
);
}
String formatPointNumber(num number, {bool addComma = false}) {
if (addComma) return NumberFormat("#,###.##", "en_US").format(number);
return number.toStringAsFixed(2);
}
// --- Data Logic ---
String _getProductName(num detailsId) {
final details = saleInvoice.salesDetails?.firstWhere(
(e) => e.id == detailsId,
orElse: () => SalesDetails(),
);
String name = details?.product?.productName ?? '';
if (details?.product?.productType == 'variant' && details?.stock?.batchNo != null) {
name += ' [${details!.stock!.batchNo}]';
}
return name;
}
num _getProductQty(num detailsId) {
num totalQty =
saleInvoice.salesDetails?.firstWhere((e) => e.id == detailsId, orElse: () => SalesDetails()).quantities ?? 0;
// Add returned quantities back logic
if (saleInvoice.salesReturns?.isNotEmpty ?? false) {
for (var ret in saleInvoice.salesReturns!) {
for (var det in ret.salesReturnDetails ?? []) {
if (det.saleDetailId == detailsId) totalQty += det.returnQty ?? 0;
}
}
}
return totalQty;
}
// --- Main Generator ---
@override
Future<List<int>> get template async {
final _profile = await CapabilityProfile.load();
final _generator = Generator(is58mm ? PaperSize.mm58 : PaperSize.mm80, _profile);
// Generate Layout
final _imageBytes = await _generateLayout();
final _image = img.decodeImage(_imageBytes);
if (_image == null) throw Exception('Failed to generate invoice.');
List<int> _bytes = [];
_bytes += _generator.image(_image);
_bytes += _generator.cut();
return _bytes;
}
Future<Uint8List> _generateLayout() async {
final _lang = lang.S.of(context);
// 1. Prepare Logo
thermer.ThermerImage? _logo;
if (business.data?.thermalInvoiceLogo != null && business.data?.showThermalInvoiceLogo == 1) {
try {
_logo = await thermer.ThermerImage.network(
APIConfig.domain + business.data!.thermalInvoiceLogo!,
width: is58mm ? 120 : 200,
height: is58mm ? 120 : 200,
);
} catch (_) {}
}
//qr logo
thermer.ThermerImage? _qrLogo;
if (business.data?.invoiceScannerLogo != null && business.data?.showInvoiceScannerLogo == 1) {
try {
_qrLogo = await thermer.ThermerImage.network(
APIConfig.domain + business.data!.invoiceScannerLogo!,
width: is58mm ? 120 : 140,
height: is58mm ? 120 : 140,
);
} catch (_) {}
}
// 2. Prepare Product Rows
final productRows = _buildProductRows();
// 3. Prepare Return Section
final returnWidgets = _buildReturnSection(context);
// 4. Build Layout
final _layout = thermer.ThermerLayout(
textDirection: isRTL ? thermer.TextDirection.rtl : thermer.TextDirection.ltr,
paperSize: is58mm ? thermer.PaperSize.mm58 : thermer.PaperSize.mm80,
widgets: [
// --- Header Section ---
if (_logo != null) ...[thermer.ThermerAlign(child: _logo), thermer.ThermerSizedBox(height: 16)],
if (business.data?.meta?.showCompanyName == 1)
thermer.ThermerText(
business.data?.companyName ?? "N/A",
style: _commonStyle(fontSize: is58mm ? 46 : 54, isBold: false),
textAlign: thermer.TextAlign.center,
),
if (saleInvoice.branch?.name != null)
thermer.ThermerText('${_lang.branch}: ${saleInvoice.branch?.name}',
style: _commonStyle(), textAlign: thermer.TextAlign.center),
if (business.data?.meta?.showAddress == 1)
if (business.data?.address != null || saleInvoice.branch?.address != null)
thermer.ThermerText(
saleInvoice.branch?.address ?? business.data?.address ?? 'N/A',
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
if (business.data?.meta?.showPhoneNumber == 1)
if (business.data?.phoneNumber != null || saleInvoice.branch?.phone != null)
thermer.ThermerText(
'${_lang.mobile} ${saleInvoice.branch?.phone ?? business.data?.phoneNumber ?? "N/A"}',
style: _commonStyle(),
textAlign: thermer.TextAlign.center,
),
if (business.data?.vatName != null && business.data?.meta?.showVat == 1)
thermer.ThermerText("${business.data?.vatName}: ${business.data?.vatNo}",
style: _commonStyle(), textAlign: thermer.TextAlign.center),
thermer.ThermerSizedBox(height: 16),
thermer.ThermerText(
_lang.invoice,
style: _commonStyle(fontSize: is58mm ? 30 : 48, isBold: true)
.copyWith(decoration: thermer.TextDecoration.underline),
textAlign: thermer.TextAlign.center,
),
thermer.ThermerSizedBox(height: 16),
// --- Info Section (Layout adjusted based on is58mm) ---
..._buildInfoSection(_lang),
thermer.ThermerSizedBox(height: 8),
// --- Product Table ---
thermer.ThermerTable(
header: thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText(_lang.sl, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.item, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.qty, textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.price, textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
thermer.ThermerText(_lang.amount, textAlign: thermer.TextAlign.end, style: _commonStyle(isBold: true)),
]),
data: productRows,
cellWidths: is58mm
? {0: null, 1: 0.2, 2: 0.15, 3: 0.2} // 58mm layout
: {0: 0.1, 1: null, 2: 0.15, 3: 0.2, 4: 0.2}, // 80mm layout
columnSpacing: 15.0,
rowSpacing: 3.0,
),
thermer.ThermerDivider.horizontal(),
// --- Totals Section ---
if (!is58mm)
// 80mm Split Layout
thermer.ThermerRow(
children: [
thermer.ThermerExpanded(flex: 4, child: thermer.ThermerAlign(child: _buildPaymentInfoText(_lang))),
thermer.ThermerExpanded(flex: 6, child: _buildCalculationColumn(_lang)),
],
)
else ...[
// 58mm Stacked Layout
_buildCalculationColumn(_lang),
thermer.ThermerDivider.horizontal(),
_buildPaymentInfoText(_lang),
],
thermer.ThermerSizedBox(height: 16),
// --- Returns ---
...returnWidgets,
// --- Footer ---
if (business.data?.gratitudeMessage != null && business.data?.showGratitudeMsg == 1)
thermer.ThermerText(business.data?.gratitudeMessage ?? '',
textAlign: thermer.TextAlign.center, style: _commonStyle(isBold: true)),
if (business.data?.showNote == 1)
thermer.ThermerText('${business.data?.invoiceNoteLevel ?? _lang.note}: ${business.data?.invoiceNote}',
textAlign: thermer.TextAlign.center, style: _commonStyle()),
thermer.ThermerSizedBox(height: 16),
if (_qrLogo != null) ...[thermer.ThermerAlign(child: _qrLogo), thermer.ThermerSizedBox(height: 1)],
// if (business.data?.developByLink != null)
// thermer.ThermerAlign(child: thermer.ThermerQRCode(data: business.data?.developByLink ?? '', size: 120)),
if (business.data?.developBy != null)
thermer.ThermerText('${business.data?.developByLevel ?? _lang.developedBy} ${business.data?.developBy}',
textAlign: thermer.TextAlign.center, style: _commonStyle()),
thermer.ThermerSizedBox(height: 200), // Cutter space
],
);
return _layout.toUint8List();
}
// --- Sub-Builders ---
List<thermer.ThermerWidget> _buildInfoSection(lang.S _lang) {
DateTime? saleDateTime;
if (saleInvoice.saleDate != null && saleInvoice.saleDate!.isNotEmpty) {
saleDateTime = DateTime.tryParse(saleInvoice.saleDate!);
}
final formattedDate = saleDateTime != null
? DateFormat('dd MMMM yyyy').format(saleDateTime) // 25 January 2026
: '';
final formattedTime = saleDateTime != null
? DateFormat('hh:mm a').format(saleDateTime).toLowerCase() // 12:55 pm
: '';
final invText = '${_lang.invoice}: ${saleInvoice.invoiceNumber ?? ''}';
final dateText = '${_lang.date}: ${formattedDate ?? ''}';
final timeText = "${_lang.time}: ${formattedTime ?? ''}";
final nameText = '${_lang.name}: ${saleInvoice.party?.name ?? 'Guest'}';
final mobileText = '${_lang.mobile} ${saleInvoice.party?.phone ?? 'N/A'}';
final salesByText =
"${_lang.salesBy} ${saleInvoice.user?.role == 'shop-owner' ? _lang.admin : saleInvoice.user?.role ?? "N/A"}";
if (is58mm) {
// 58mm: Vertical Stack (One below another)
return [
thermer.ThermerText(
invText,
style: _commonStyle(),
),
if (saleInvoice.saleDate != null) thermer.ThermerText(dateText, style: _commonStyle()),
thermer.ThermerText(nameText, style: _commonStyle()),
thermer.ThermerText(mobileText, style: _commonStyle()),
thermer.ThermerText(salesByText, style: _commonStyle()),
];
} else {
// 80mm: Two columns (Side by side)
return [
// Row 1: Invoice | Date
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(invText, style: _commonStyle()),
if (saleInvoice.saleDate != null) thermer.ThermerText(dateText, style: _commonStyle()),
],
),
// Row 2: Name | Time
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(nameText, style: _commonStyle()),
thermer.ThermerText(timeText, style: _commonStyle()),
],
),
// Row 3: Mobile | Sales By
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(mobileText, style: _commonStyle()),
thermer.ThermerText(salesByText, style: _commonStyle()),
],
),
];
}
}
List<thermer.ThermerTableRow> _buildProductRows() {
List<thermer.ThermerTableRow> rows = [];
if (saleInvoice.salesDetails == null) return rows;
for (var index = 0; index < saleInvoice.salesDetails!.length; index++) {
final item = saleInvoice.salesDetails![index];
final qty = _getProductQty(item.id ?? 0);
final price = item.price ?? 0;
final discount = item.discount ?? 0;
final amount = (price * qty) - (discount * qty);
// Main Row
rows.add(thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText((index + 1).toString(), style: _commonStyle()),
thermer.ThermerText(_getProductName(item.id ?? 0), style: _commonStyle()),
thermer.ThermerText(formatPointNumber(qty), textAlign: thermer.TextAlign.center, style: _commonStyle()),
thermer.ThermerText('$price', textAlign: thermer.TextAlign.center, style: _commonStyle()),
thermer.ThermerText(formatPointNumber(amount), textAlign: thermer.TextAlign.end, style: _commonStyle()),
]));
// Warranty/Guarantee
final w = item.warrantyInfo;
if (w?.warrantyDuration != null) {
rows.add(_buildInfoRow("${lang.S.of(context).warranty} : ${w!.warrantyDuration} ${w.warrantyUnit}"));
}
if (w?.guaranteeDuration != null) {
rows.add(_buildInfoRow("${lang.S.of(context).guarantee} : ${w!.guaranteeDuration} ${w.guaranteeUnit}"));
}
}
return rows;
}
thermer.ThermerTableRow _buildInfoRow(String text) {
return thermer.ThermerTableRow([
if (!is58mm) thermer.ThermerText(""),
thermer.ThermerText(text, style: _commonStyle(fontSize: 20)),
thermer.ThermerText(""),
thermer.ThermerText(""),
thermer.ThermerText(""),
]);
}
thermer.ThermerColumn _buildCalculationColumn(lang.S _lang) {
thermer.ThermerRow calcRow(String label, num value, {bool bold = false, bool isCurrency = true}) {
return thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(label, style: _commonStyle(isBold: bold)),
thermer.ThermerText(
isCurrency ? formatPointNumber(value, addComma: true) : value.toString(),
textAlign: thermer.TextAlign.end,
style: _commonStyle(isBold: bold),
),
],
);
}
num subTotal = 0;
num totalDiscount = 0;
if (saleInvoice.salesDetails != null) {
for (var e in saleInvoice.salesDetails!) {
final q = _getProductQty(e.id ?? 0);
subTotal += ((e.price ?? 0) * q) - ((e.discount ?? 0) * q);
totalDiscount += (e.discount ?? 0) * q;
}
}
num returnDiscount = 0;
if (saleInvoice.salesReturns != null) {
for (var ret in saleInvoice.salesReturns!) {
for (var det in ret.salesReturnDetails ?? []) {
final price = saleInvoice.salesDetails
?.firstWhere((e) => e.id == det.saleDetailId, orElse: () => SalesDetails())
.price ??
0;
returnDiscount += ((price * (det.returnQty ?? 0)) - (det.returnAmount ?? 0));
}
}
}
return thermer.ThermerColumn(
children: [
calcRow('${_lang.subTotal}: ', subTotal),
calcRow('${_lang.discount}: ', (saleInvoice.discountAmount ?? 0) + returnDiscount + totalDiscount),
calcRow("${saleInvoice.vat?.name ?? _lang.vat}: ", saleInvoice.vatAmount ?? 0, isCurrency: false),
calcRow('${_lang.shippingCharge}:', saleInvoice.shippingCharge ?? 0, isCurrency: false),
if ((saleInvoice.roundingAmount ?? 0) != 0) ...[
calcRow('${_lang.total}:', saleInvoice.actualTotalAmount ?? 0),
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText('${_lang.rounding}:', style: _commonStyle()),
thermer.ThermerText(
"${!(saleInvoice.roundingAmount?.isNegative ?? true) ? '+' : ''}${formatPointNumber(saleInvoice.roundingAmount ?? 0)}",
textAlign: thermer.TextAlign.end,
style: _commonStyle(),
),
],
),
],
thermer.ThermerDivider.horizontal(),
calcRow('${_lang.totalPayable}: ', saleInvoice.totalAmount ?? 0, bold: true),
calcRow('${_lang.paidAmount}: ',
((saleInvoice.totalAmount ?? 0) - (saleInvoice.dueAmount ?? 0)) + (saleInvoice.changeAmount ?? 0)),
if ((saleInvoice.dueAmount ?? 0) > 0) calcRow('${_lang.dueAmount}: ', saleInvoice.dueAmount ?? 0),
if ((saleInvoice.changeAmount ?? 0) > 0) calcRow('${_lang.changeAmount}: ', saleInvoice.changeAmount ?? 0),
],
);
}
thermer.ThermerText _buildPaymentInfoText(lang.S _lang) {
List<String> labels = [];
if (saleInvoice.transactions != null) {
for (var item in saleInvoice.transactions!) {
String label = item.paymentType?.name ?? 'n/a';
if (item.transactionType == 'cash_payment') label = _lang.cash;
if (item.transactionType == 'cheque_payment') label = _lang.cheque;
if (item.transactionType == 'wallet_payment') label = _lang.wallet;
labels.add(label);
}
}
return thermer.ThermerText(
"${_lang.paidVia} : ${labels.join(', ')}",
style: _commonStyle(),
textAlign: is58mm ? thermer.TextAlign.center : thermer.TextAlign.start,
);
}
List<thermer.ThermerWidget> _buildReturnSection(BuildContext context) {
final _lang = lang.S.of(context);
if (saleInvoice.salesReturns?.isEmpty ?? true) return [];
List<thermer.ThermerWidget> widgets = [];
List<String> processedDates = [];
num totalReturnedAmount = 0;
for (var ret in saleInvoice.salesReturns!) {
final dateStr = ret.returnDate?.substring(0, 10);
if (dateStr != null && !processedDates.contains(dateStr)) {
processedDates.add(dateStr);
widgets.add(thermer.ThermerDivider.horizontal());
widgets.add(thermer.ThermerText('${_lang.retur}-$dateStr', style: _commonStyle(isBold: true)));
}
widgets.add(thermer.ThermerTable(
header: thermer.ThermerTableRow([
thermer.ThermerText(_lang.item, style: _commonStyle(fontSize: 22, isBold: true)),
thermer.ThermerText(_lang.qty,
textAlign: thermer.TextAlign.center, style: _commonStyle(fontSize: 22, isBold: true)),
thermer.ThermerText(_lang.total,
textAlign: thermer.TextAlign.end, style: _commonStyle(fontSize: 22, isBold: true)),
]),
data: (ret.salesReturnDetails ?? []).map((d) {
totalReturnedAmount += d.returnAmount ?? 0;
return thermer.ThermerTableRow([
thermer.ThermerText(_getProductName(d.saleDetailId ?? 0), style: _commonStyle(fontSize: 22)),
thermer.ThermerText('${d.returnQty ?? 0}',
textAlign: thermer.TextAlign.center, style: _commonStyle(fontSize: 22)),
thermer.ThermerText('${d.returnAmount ?? 0}',
textAlign: thermer.TextAlign.end, style: _commonStyle(fontSize: 22)),
]);
}).toList(),
cellWidths: {0: null, 1: 0.2, 2: 0.25},
));
}
widgets.add(thermer.ThermerDivider.horizontal());
widgets.add(
thermer.ThermerRow(
mainAxisAlignment: thermer.ThermerMainAxisAlignment.spaceBetween,
children: [
thermer.ThermerText(_lang.returnAmount, style: _commonStyle()),
thermer.ThermerText(formatPointNumber(totalReturnedAmount),
textAlign: thermer.TextAlign.end, style: _commonStyle()),
],
),
);
widgets.add(thermer.ThermerSizedBox(height: 10));
return widgets;
}
}

View File

@@ -0,0 +1,69 @@
// import 'dart:typed_data' show Uint8List;
// import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
// import 'package:flutter/material.dart';
// import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod;
// import 'package:image/image.dart' as img;
// import 'package:intl/intl.dart';
// import 'package:mobile_pos/generated/l10n.dart' as lang;
// import 'package:mobile_pos/generated/l10n.dart';
// import 'package:mobile_pos/model/business_info_model.dart';
// import 'package:mobile_pos/model/sale_transaction_model.dart';
// import '../../thermer/thermer.dart' as thermer;
// // part '_purchase_invoice_template.dart';
// part '_sale_invoice_template.dart';
// // part '_kot_ticket_template.dart';
// // part '_due_collection_invoice_template.dart';
// abstract class ThermalInvoiceTemplateBase {
// ThermalInvoiceTemplateBase(this.ref);
// final riverpod.Ref ref;
// thermer.TextDirection get textDirection {
// final _rtlLang = ['ar', 'ar-bh', 'eg-ar', 'fa', 'prs', 'ps', 'ur'];
// // if (_rtlLang.contains(ref.read(GlobalContextHolder.localeProvider).languageCode)) {
// // return thermer.TextDirection.rtl;
// // }
// return thermer.TextDirection.ltr;
// }
// Future<List<int>> get template;
// // Future<img.Image?> getNetworkImage(
// // String? url, {
// // int width = 100,
// // int height = 100,
// // }) async {
// // if (url == null) return null;
// // try {
// // final _response = await dio.Dio().get<List<int>>(
// // url,
// // options: dio.Options(responseType: dio.ResponseType.bytes),
// // );
// // final _image = img.decodeImage(Uint8List.fromList(_response.data!));
// // if (_image == null) return null;
// // return img.copyResize(
// // _image,
// // width: width,
// // height: height,
// // interpolation: img.Interpolation.average,
// // );
// // } catch (e) {
// // return null;
// // }
// // }
// }
// // extension ThermalPrinterPaperSizeExt {
// // PaperSize get escPosSize {
// // return switch (this) {
// // ThermalPrinterPaperSize.mm582Inch => PaperSize.mm58,
// // ThermalPrinterPaperSize.mm803Inch => PaperSize.mm80,
// // };
// // }
// // }

View File

@@ -0,0 +1,79 @@
// import '../../../../data/repository/repository.dart';
// export '../../../../data/repository/repository.dart' show SalePurchaseThermalInvoiceData;
// final purchaseThermalInvoiceProvider = FutureProvider.autoDispose.family<bool, SalePurchaseThermalInvoiceData>(
// (ref, purchase) async {
// final _user = ref.read(userRepositoryProvider).value;
// final _template = PurchaseThermalInvoiceTemplate(
// ref,
// purchaseInvoice: purchase.copyWith(user: _user),
// );
// return await Future.microtask(
// () => ref.read(thermalPrinterGeneratorProvider).printInvoice(_template),
// );
// },
// );
// final saleThermalInvoiceProvider =
// FutureProvider.autoDispose.family<bool, ({SalePurchaseThermalInvoiceData sale, bool printKOT})>(
// (ref, data) async {
// final _user = ref.read(userRepositoryProvider).value;
// final _template = SaleThermalInvoiceTemplate(
// ref,
// saleInvoice: data.sale.copyWith(user: _user),
// printKOT: data.printKOT,
// );
// return await Future.microtask(
// () => ref.read(thermalPrinterGeneratorProvider).printInvoice(_template),
// );
// },
// );
// final kotThermalInvoiceProvider = FutureProvider.autoDispose.family<bool, Sale>(
// (ref, sale) async {
// final _user = ref.read(userRepositoryProvider).value;
// final _template = KOTTicketTemplate(
// ref,
// kotInvoice: SalePurchaseThermalInvoiceData.fromSale(sale).copyWith(user: _user),
// );
// return await Future.microtask(
// () => ref.read(thermalPrinterGeneratorProvider).printInvoice(_template),
// );
// },
// );
// final dueCollectionThermalInvoiceProvider = FutureProvider.autoDispose.family<bool, DueCollectionThermalInvoiceData>(
// (ref, dueCollect) async {
// final _user = ref.read(userRepositoryProvider).value;
// final _template = DueCollectionTemplate(
// ref,
// dueInvoice: dueCollect.copyWith(user: _user),
// );
// return await Future.microtask(
// () => ref.read(thermalPrinterGeneratorProvider).printInvoice(_template),
// );
// },
// );
// sealed class InvoicePreviewType {
// const InvoicePreviewType._();
// }
// class ThermalPreview extends InvoicePreviewType {
// final ThermalPrintInvoiceData printData;
// final bool isSale;
// ThermalPreview(this.printData, {this.isSale = false}) : super._();
// }
// class PdfPreview extends InvoicePreviewType {
// PdfPreview() : super._();
// }

View File

@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import '../widgets/widgets.export.dart';
class TextMeasurementCache {
static final Map<String, _CachedMeasurement> _cache = {};
static void clear() {
_cache.clear();
}
static _CachedMeasurement _getCachedMeasurement(
String text,
TextStyle style,
TextDirection direction,
TextAlign textAlign,
int? maxLines,
double maxWidth,
List<String>? fallbackFonts,
) {
final key = _generateKey(
text,
style,
direction,
textAlign,
maxLines,
maxWidth,
fallbackFonts,
);
if (_cache.length >= 1000) {
_cache.clear();
}
return _cache[key] ??= _CachedMeasurement(
text,
style,
direction,
textAlign,
maxLines,
maxWidth,
fallbackFonts,
);
}
static String _generateKey(
String text,
TextStyle style,
TextDirection direction,
TextAlign textAlign,
int? maxLines,
double maxWidth,
List<String>? fallbackFonts,
) {
return '$text|${style.hashCode}|$direction|$textAlign|$maxLines|$maxWidth|${fallbackFonts?.join(',')}';
}
static double getWidth(
String text,
TextStyle style,
TextDirection direction,
TextAlign textAlign,
int? maxLines,
double maxWidth, [
List<String>? fallbackFonts,
]) {
return _getCachedMeasurement(
text,
style,
direction,
textAlign,
maxLines,
maxWidth,
fallbackFonts,
).width;
}
static double getHeight(
String text,
TextStyle style,
TextDirection direction,
TextAlign textAlign,
int? maxLines,
double maxWidth, [
List<String>? fallbackFonts,
]) {
return _getCachedMeasurement(
text,
style,
direction,
textAlign,
maxLines,
maxWidth,
fallbackFonts,
).height;
}
static TextPainter getPainter(
String text,
TextStyle style,
TextDirection direction,
TextAlign textAlign,
int? maxLines,
double maxWidth, [
List<String>? fallbackFonts,
]) {
return _getCachedMeasurement(
text,
style,
direction,
textAlign,
maxLines,
maxWidth,
fallbackFonts,
).painter;
}
}
class _CachedMeasurement {
late final TextPainter painter;
late final double width;
late final double height;
_CachedMeasurement(
String text,
TextStyle style,
TextDirection direction,
TextAlign textAlign,
int? maxLines,
double maxWidth,
List<String>? fallbackFonts,
) {
final effectiveStyle =
fallbackFonts != null && fallbackFonts.isNotEmpty ? style.copyWith(fontFamilyFallback: fallbackFonts) : style;
if (fallbackFonts != null && fallbackFonts.isNotEmpty) {
debugPrint('TextMeasurementCache: Using font fallback for text: "$text"');
}
painter = TextPainter(
text: TextSpan(text: text, style: effectiveStyle),
textDirection: direction,
textAlign: textAlign,
maxLines: maxLines,
);
painter.layout(maxWidth: maxWidth);
width = painter.width;
height = painter.height;
}
}
class LayoutUtils {
static List<double> calculateCellWidths(
ThermerTable table,
double availableWidth,
int numColumns,
) {
List<double> actualWidths = [];
if (table.cellWidths != null) {
double totalFixed = 0;
int nullCount = 0;
for (int col = 0; col < numColumns; col++) {
double? frac = table.cellWidths![col];
if (frac != null) {
totalFixed += frac;
} else {
nullCount++;
}
}
double remaining = 1.0 - totalFixed;
double nullFrac = nullCount > 0 ? remaining / nullCount : 0;
for (int col = 0; col < numColumns; col++) {
double? frac = table.cellWidths![col];
actualWidths.add((frac ?? nullFrac) * availableWidth);
}
} else {
double totalSpacing = (numColumns - 1) * table.columnSpacing;
double cellWidth = (availableWidth - totalSpacing) / numColumns;
actualWidths = List.filled(numColumns, cellWidth);
}
return actualWidths;
}
static double calculateWidgetWidth(
ThermerWidget widget,
double availableWidth, {
TextDirection defaultTextDirection = TextDirection.ltr,
}) {
if (widget is ThermerText) {
return TextMeasurementCache.getWidth(
widget.data,
widget.style ?? const TextStyle(),
widget.direction ?? defaultTextDirection,
widget.textAlign,
widget.maxLines,
availableWidth,
widget.fallbackFonts,
);
} else if (widget is ThermerSizedBox) {
return widget.width ?? availableWidth;
} else if (widget is ThermerQRCode) {
return widget.size;
} else if (widget is ThermerImage) {
return widget.width ?? availableWidth;
} else if (widget is ThermerExpanded) {
return calculateWidgetWidth(widget.child, availableWidth, defaultTextDirection: defaultTextDirection);
} else if (widget is ThermerFlexible) {
return calculateWidgetWidth(widget.child, availableWidth, defaultTextDirection: defaultTextDirection);
} else if (widget is ThermerAlign) {
return availableWidth;
}
return availableWidth;
}
static double calculateWidgetHeight(
ThermerWidget widget,
double maxWidth, {
TextDirection defaultTextDirection = TextDirection.ltr,
}) {
if (widget is ThermerText) {
return TextMeasurementCache.getHeight(
widget.data,
widget.style ?? const TextStyle(),
widget.direction ?? defaultTextDirection,
widget.textAlign,
widget.maxLines,
maxWidth,
widget.fallbackFonts,
);
} else if (widget is ThermerRow) {
double maxHeight = 0;
for (final child in widget.children) {
final childHeight = calculateWidgetHeight(child, maxWidth, defaultTextDirection: defaultTextDirection);
if (childHeight > maxHeight) maxHeight = childHeight;
}
return maxHeight;
} else if (widget is ThermerColumn) {
double totalHeight = 0;
for (int i = 0; i < widget.children.length; i++) {
totalHeight += calculateWidgetHeight(widget.children[i], maxWidth, defaultTextDirection: defaultTextDirection);
if (i < widget.children.length - 1) totalHeight += widget.spacing;
}
return totalHeight;
} else if (widget is ThermerTable) {
final numColumns = widget.data.isNotEmpty ? widget.data[0].cells.length : (widget.header?.cells.length ?? 0);
final actualWidths = calculateCellWidths(widget, maxWidth, numColumns);
double totalHeight = 0;
double borderHeight = 0;
if (widget.enableHeaderBorders && widget.header != null) {
final textPainter = TextPainter(
text: TextSpan(
text: widget.horizontalBorderChar,
style: TextStyle(fontSize: 20, color: Color(0xFF000000), fontWeight: FontWeight.w500),
),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: maxWidth);
borderHeight = textPainter.height;
}
if (widget.header != null) {
if (widget.enableHeaderBorders) {
totalHeight += borderHeight;
}
double rowHeight = 0;
for (int col = 0; col < widget.header!.cells.length; col++) {
final cell = widget.header!.cells[col];
final cellWidth = actualWidths[col];
final cellHeight = calculateWidgetHeight(cell, cellWidth, defaultTextDirection: defaultTextDirection);
if (cellHeight > rowHeight) rowHeight = cellHeight;
}
totalHeight += rowHeight;
if (widget.enableHeaderBorders) {
totalHeight += widget.rowSpacing + borderHeight;
} else {
totalHeight += widget.rowSpacing;
}
}
for (int i = 0; i < widget.data.length; i++) {
double rowHeight = 0;
for (int col = 0; col < widget.data[i].cells.length; col++) {
final cell = widget.data[i].cells[col];
final cellWidth = actualWidths[col];
final cellHeight = calculateWidgetHeight(cell, cellWidth, defaultTextDirection: defaultTextDirection);
if (cellHeight > rowHeight) rowHeight = cellHeight;
}
totalHeight += rowHeight;
if (i < widget.data.length - 1) totalHeight += widget.rowSpacing;
}
return totalHeight;
} else if (widget is ThermerDivider) {
if (widget.isHorizontal) {
final textPainter = TextPainter(
text: TextSpan(
text: widget.character,
style: TextStyle(fontSize: 20, color: Color(0xFF000000), fontWeight: FontWeight.w500),
),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: maxWidth);
return textPainter.height;
} else {
return (widget.length ?? 1) * 20.0;
}
} else if (widget is ThermerQRCode) {
return widget.size;
} else if (widget is ThermerImage) {
return widget.height ?? ((widget.width ?? maxWidth) / widget.image.width) * widget.image.height;
} else if (widget is ThermerSizedBox) {
return widget.height ??
(widget.child != null
? calculateWidgetHeight(widget.child!, maxWidth, defaultTextDirection: defaultTextDirection)
: 0);
} else if (widget is ThermerExpanded) {
return calculateWidgetHeight(widget.child, maxWidth, defaultTextDirection: defaultTextDirection);
} else if (widget is ThermerFlexible) {
return calculateWidgetHeight(widget.child, maxWidth, defaultTextDirection: defaultTextDirection);
} else if (widget is ThermerAlign) {
return calculateWidgetHeight(widget.child, maxWidth, defaultTextDirection: defaultTextDirection);
}
return 0;
}
}

View File

@@ -0,0 +1,11 @@
import '../widgets/widgets.export.dart';
class LayoutItem {
final ThermerWidget widget;
final double height;
const LayoutItem({
required this.widget,
required this.height,
});
}

View File

@@ -0,0 +1,152 @@
import 'dart:typed_data' as type;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import '_shared_types.dart';
import '_thermer_painter.dart';
import '_layout_utils.dart';
import '../widgets/widgets.export.dart';
class PaperSize {
const PaperSize._(this._width);
static const mm58 = PaperSize._(58.0);
static const mm80 = PaperSize._(80.0);
static const mm110 = PaperSize._(110.0);
const PaperSize.custom(double width) : _width = width;
final double _width;
double get width => _width;
}
/// Main class for creating thermal printer layouts from widgets.
/// Handles layout calculation, rendering to image, and conversion to byte data.
class ThermerLayout {
/// The list of widgets to include in the layout.
final List<ThermerWidget> widgets;
/// The paper size for the thermal printer.
final PaperSize paperSize;
/// Dots per inch for the printer resolution.
final double dpi;
/// Gap between layout items.
final double layoutGap;
/// Whether to convert the output to black and white.
final bool blackAndWhite;
/// Horizontal margin in millimeters to account for printer limitations.
final double marginMm;
/// The default text direction for the layout.
final TextDirection textDirection;
const ThermerLayout({
required this.widgets,
this.paperSize = PaperSize.mm80,
double? dpi,
this.layoutGap = 3.0,
this.blackAndWhite = false,
double? marginMm,
this.textDirection = TextDirection.ltr,
}) : dpi = dpi ?? 203.0,
marginMm = marginMm ?? 5.0;
double get width => ((paperSize.width - (marginMm * 2)) / 25.4) * dpi;
// Process layout and calculate heights in one pass
List<LayoutItem> _processLayout() {
return widgets.map((widget) {
final height = _calculateHeight(widget);
return LayoutItem(widget: widget, height: height);
}).toList();
}
double _calculateHeight(ThermerWidget widget) {
// subtract margins from width to get printable area
final printableWidth = width;
final height = LayoutUtils.calculateWidgetHeight(widget, printableWidth, defaultTextDirection: textDirection);
if (height == 0 && widget is ThermerText) {
throw Exception('ThermerText height is 0 for text: "${widget.data}"');
}
return height;
}
double _calculateTotalHeight(List<LayoutItem> items) {
double total = 0;
for (int i = 0; i < items.length; i++) {
total += items[i].height;
if (i < items.length - 1) total += layoutGap;
}
return total;
}
// Public API methods
Future<type.Uint8List> toUint8List() => generateImage();
Future<type.Uint8List> generateImage() async {
TextMeasurementCache.clear();
if (widgets.isEmpty) {
throw Exception('No widgets provided to ThermerLayout');
}
final layoutItems = _processLayout();
final totalHeight = _calculateTotalHeight(layoutItems);
if (totalHeight <= 1) {
throw Exception('Total height is $totalHeight, cannot generate image');
}
if (width <= 0 || width > 10000) {
throw Exception('Invalid width: $width. Must be > 0 and <= 10000');
}
if (totalHeight <= 0 || totalHeight > 10000) {
throw Exception('Invalid height: $totalHeight. Must be > 0 and <= 10000');
}
debugPrint('ThermerLayout: Generating image with size ${width.toInt()}x${totalHeight.toInt()}');
final size = ui.Size(width, totalHeight);
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
// Draw white background
final paint = Paint()..color = const Color(0xFFFFFFFF);
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
final painter = ThermerPainter(layoutItems, layoutGap: layoutGap, textDirection: textDirection);
painter.paint(canvas, size);
final picture = recorder.endRecording();
final image = await picture.toImage(
size.width.toInt(),
size.height.toInt(),
);
var byteData = await image.toByteData(format: ui.ImageByteFormat.png);
var bytes = byteData!.buffer.asUint8List();
if (blackAndWhite) {
debugPrint('ThermerLayout: Converting image to black and white');
// Decode the PNG
final decodedImage = img.decodePng(bytes);
if (decodedImage != null) {
// Convert to monochrome (1-bit)
final monoImage = img.monochrome(decodedImage);
// Encode back to PNG
bytes = img.encodePng(monoImage);
debugPrint('ThermerLayout: B&W conversion completed');
} else {
debugPrint('ThermerLayout: Failed to decode image for B&W conversion');
}
}
return bytes;
}
}

View File

@@ -0,0 +1,593 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '_shared_types.dart';
import '_layout_utils.dart';
import '../widgets/widgets.export.dart';
class ThermerPainter extends CustomPainter {
final List<LayoutItem> layoutItems;
final double layoutGap;
final TextDirection textDirection;
static const double charWidth = 10;
ThermerPainter(this.layoutItems,
{this.layoutGap = 3.0, this.textDirection = TextDirection.ltr});
@override
void paint(Canvas canvas, Size size) {
double yOffset = 0;
final linePaint = Paint()
..color = const Color(0xFF000000)
..strokeWidth = 1;
for (final item in layoutItems) {
_paintWidget(canvas, size, item.widget, yOffset, linePaint);
yOffset += item.height + layoutGap;
}
}
void _paintWidget(Canvas canvas, Size size, ThermerWidget widget,
double yOffset, Paint linePaint) {
if (widget is ThermerText) {
final textPainter = TextMeasurementCache.getPainter(
widget.data,
widget.style ?? const TextStyle(),
widget.direction ?? textDirection,
widget.textAlign,
widget.maxLines,
size.width,
widget.fallbackFonts,
);
double xOffset = 0;
final effectiveAlign = widget.textAlign;
if (effectiveAlign == TextAlign.center) {
xOffset = (size.width - textPainter.width) / 2;
} else if (effectiveAlign == TextAlign.right) {
xOffset = size.width - textPainter.width;
} else if (effectiveAlign == TextAlign.left) {
xOffset = 0;
} else if (effectiveAlign == TextAlign.start) {
xOffset = textDirection == TextDirection.rtl
? size.width - textPainter.width
: 0;
} else if (effectiveAlign == TextAlign.end) {
xOffset = textDirection == TextDirection.rtl
? 0
: size.width - textPainter.width;
} else if (effectiveAlign == TextAlign.justify) {
xOffset = textDirection == TextDirection.rtl
? size.width - textPainter.width
: 0;
}
textPainter.paint(canvas, Offset(xOffset, yOffset));
} else if (widget is ThermerRow) {
_paintRow(canvas, size, widget, yOffset, linePaint);
} else if (widget is ThermerColumn) {
_paintColumn(canvas, size, widget, yOffset, linePaint);
} else if (widget is ThermerTable) {
_paintTable(canvas, size, widget, yOffset, linePaint);
} else if (widget is ThermerDivider) {
_paintDivider(canvas, size, widget, yOffset, linePaint);
} else if (widget is ThermerImage) {
_paintImage(canvas, size, widget, yOffset);
} else if (widget is ThermerQRCode) {
_paintQRCode(canvas, size, widget, yOffset);
} else if (widget is ThermerAlign) {
_paintAlign(canvas, size, widget, yOffset, linePaint);
} else if (widget is ThermerExpanded) {
_paintWidget(canvas, size, widget.child, yOffset, linePaint);
} else if (widget is ThermerFlexible) {
_paintWidget(canvas, size, widget.child, yOffset, linePaint);
} else if (widget is ThermerSizedBox) {
if (widget.child != null) {
final childSize = Size(
widget.width ?? size.width,
widget.height ?? _calculateChildHeight(widget.child!, size.width),
);
_paintWidget(canvas, childSize, widget.child!, yOffset, linePaint);
}
} else {
throw Exception('Unknown widget type: ${widget.runtimeType}');
}
}
void _paintTable(Canvas canvas, Size size, ThermerTable widget,
double yOffset, Paint linePaint) {
final numColumns = widget.data.isNotEmpty
? widget.data[0].cells.length
: (widget.header?.cells.length ?? 0);
final actualWidths =
LayoutUtils.calculateCellWidths(widget, size.width, numColumns);
double currentY = yOffset;
final isRtl = textDirection == TextDirection.rtl;
double getColumnX(int colIndex) {
if (!isRtl) {
double x = 0;
for (int i = 0; i < colIndex; i++) {
x += actualWidths[i];
if (widget.cellWidths == null) x += widget.columnSpacing;
}
return x;
} else {
double rightEdgeOffset = 0;
for (int i = 0; i < colIndex; i++) {
rightEdgeOffset += actualWidths[i];
if (widget.cellWidths == null)
rightEdgeOffset += widget.columnSpacing;
}
return size.width - rightEdgeOffset - actualWidths[colIndex];
}
}
if (widget.header != null) {
if (widget.enableHeaderBorders) {
_paintHorizontalBorder(
canvas, size, currentY, widget.horizontalBorderChar);
currentY += _getBorderHeight(size.width, widget.horizontalBorderChar);
}
double rowHeight = 0;
for (int col = 0; col < widget.header!.cells.length; col++) {
final cellWidget = widget.header!.cells[col];
final cellWidth = actualWidths[col];
final cellSize = Size(cellWidth, double.infinity);
final x = getColumnX(col);
canvas.save();
canvas.translate(x, 0);
_paintWidget(canvas, cellSize, cellWidget, currentY, linePaint);
canvas.restore();
final cellHeight = _calculateChildHeight(cellWidget, cellWidth);
if (cellHeight > rowHeight) rowHeight = cellHeight;
}
currentY += rowHeight;
if (widget.enableHeaderBorders) {
currentY += widget.rowSpacing;
_paintHorizontalBorder(
canvas, size, currentY, widget.horizontalBorderChar);
currentY += _getBorderHeight(size.width, widget.horizontalBorderChar);
}
currentY += widget.rowSpacing;
}
for (int i = 0; i < widget.data.length; i++) {
final row = widget.data[i];
double rowHeight = 0;
for (int col = 0; col < row.cells.length; col++) {
final cellWidget = row.cells[col];
final cellWidth = actualWidths[col];
final cellSize = Size(cellWidth, double.infinity);
final x = getColumnX(col);
canvas.save();
canvas.translate(x, 0);
_paintWidget(canvas, cellSize, cellWidget, currentY, linePaint);
canvas.restore();
final cellHeight = _calculateChildHeight(cellWidget, cellWidth);
if (cellHeight > rowHeight) rowHeight = cellHeight;
}
currentY += rowHeight;
if (i < widget.data.length - 1) currentY += widget.rowSpacing;
}
}
void _paintHorizontalBorder(
Canvas canvas, Size size, double yOffset, String char) {
const defaultStyle = TextStyle(
fontSize: 20, color: Color(0xFF000000), fontWeight: FontWeight.w500);
final borderLength =
_calculateDividerLength(char, size.width, defaultStyle);
final borderPainter = TextMeasurementCache.getPainter(
char * borderLength,
defaultStyle,
TextDirection.ltr,
TextAlign.left,
null,
size.width,
);
borderPainter.paint(canvas, Offset(0, yOffset));
}
double _getBorderHeight(double width, String char) {
const defaultStyle = TextStyle(
fontSize: 20, color: Color(0xFF000000), fontWeight: FontWeight.w500);
final borderLength = _calculateDividerLength(char, width, defaultStyle);
final borderPainter = TextMeasurementCache.getPainter(
char * borderLength,
defaultStyle,
TextDirection.ltr,
TextAlign.left,
null,
width,
);
return borderPainter.height;
}
void _paintRow(Canvas canvas, Size size, ThermerRow row, double yOffset,
Paint linePaint) {
if (row.children.isEmpty) return;
final fixedChildren = <ThermerWidget>[];
final flexibleChildren = <ThermerWidget>[];
final fixedIndices = <int>[];
final flexibleIndices = <int>[];
final flexValues = <int>[];
for (int i = 0; i < row.children.length; i++) {
final child = row.children[i];
if (child is ThermerExpanded) {
flexibleChildren.add(child);
flexibleIndices.add(i);
flexValues.add(child.flex);
} else if (child is ThermerFlexible &&
child.fit == ThermerFlexFit.loose) {
flexibleChildren.add(child);
flexibleIndices.add(i);
flexValues.add(child.flex);
} else {
fixedChildren.add(child);
fixedIndices.add(i);
}
}
final fixedWidths = fixedChildren
.map((child) => _calculateChildWidth(child, size.width))
.toList();
final childHeights = row.children
.map((child) => _calculateChildHeight(
child, _calculateChildWidth(child, size.width)))
.toList();
final rowHeight = childHeights.reduce((a, b) => a > b ? a : b);
final totalFixedWidth =
fixedWidths.isNotEmpty ? fixedWidths.reduce((a, b) => a + b) : 0;
final totalSpacing = (row.children.length - 1) * row.spacing;
final remainingWidth = size.width - totalFixedWidth - totalSpacing;
final totalFlex =
flexValues.isNotEmpty ? flexValues.reduce((a, b) => a + b) : 0;
final flexibleWidths = <double>[];
if (totalFlex > 0 && remainingWidth > 0) {
for (final flex in flexValues) {
flexibleWidths.add((flex / totalFlex) * remainingWidth);
}
} else {
flexibleWidths.addAll(List.filled(flexibleChildren.length, 0.0));
}
final actualWidths = List<double>.filled(row.children.length, 0);
for (int i = 0; i < fixedIndices.length; i++) {
actualWidths[fixedIndices[i]] = fixedWidths[i];
}
for (int i = 0; i < flexibleIndices.length; i++) {
actualWidths[flexibleIndices[i]] = flexibleWidths[i];
}
final totalChildrenWidth = actualWidths.reduce((a, b) => a + b);
final isRtl = textDirection == TextDirection.rtl;
double startX = 0;
double dynamicSpacing = row.spacing;
var effectiveAlignment = row.mainAxisAlignment;
switch (effectiveAlignment) {
case ThermerMainAxisAlignment.start:
startX = isRtl ? size.width : 0;
break;
case ThermerMainAxisAlignment.center:
final offset = (size.width -
totalChildrenWidth -
(row.children.length - 1) * row.spacing) /
2;
startX = isRtl ? size.width - offset : offset;
break;
case ThermerMainAxisAlignment.end:
final offset = size.width -
totalChildrenWidth -
(row.children.length - 1) * row.spacing;
startX = isRtl ? size.width - offset : offset;
startX = isRtl
? totalChildrenWidth + (row.children.length - 1) * row.spacing
: size.width -
totalChildrenWidth -
(row.children.length - 1) * row.spacing;
break;
case ThermerMainAxisAlignment.spaceBetween:
startX = isRtl ? size.width : 0;
if (row.children.isNotEmpty && row.children.length > 1) {
dynamicSpacing =
(size.width - totalChildrenWidth) / (row.children.length - 1);
}
break;
case ThermerMainAxisAlignment.spaceAround:
final totalSpace = size.width - totalChildrenWidth;
final spacePerChild =
row.children.isNotEmpty ? totalSpace / row.children.length : 0.0;
startX = isRtl ? size.width - spacePerChild / 2 : spacePerChild / 2;
dynamicSpacing = spacePerChild;
break;
case ThermerMainAxisAlignment.spaceEvenly:
final totalSpace = size.width - totalChildrenWidth;
final spacePerGap = row.children.isNotEmpty
? totalSpace / (row.children.length + 1)
: 0.0;
startX = isRtl ? size.width - spacePerGap : spacePerGap;
dynamicSpacing = spacePerGap;
break;
}
double currentX = startX;
for (int i = 0; i < row.children.length; i++) {
final child = row.children[i];
final childWidth = actualWidths[i];
final childHeight = childHeights[i];
double childY = yOffset;
double effectiveChildHeight = rowHeight;
if (row.crossAxisAlignment == ThermerCrossAxisAlignment.center) {
childY += (rowHeight - childHeight) / 2;
} else if (row.crossAxisAlignment == ThermerCrossAxisAlignment.end) {
childY += rowHeight - childHeight;
} else if (row.crossAxisAlignment == ThermerCrossAxisAlignment.stretch) {
effectiveChildHeight = rowHeight;
childY = yOffset;
} else {
effectiveChildHeight = childHeight;
childY = yOffset;
}
double paintX;
if (isRtl) {
currentX -= childWidth;
paintX = currentX;
} else {
paintX = currentX;
currentX += childWidth;
}
canvas.save();
canvas.translate(paintX, 0);
_paintWidget(canvas, Size(childWidth, effectiveChildHeight), child,
childY, linePaint);
canvas.restore();
if (i < row.children.length - 1) {
if (isRtl) {
currentX -= dynamicSpacing;
} else {
currentX += dynamicSpacing;
}
}
}
}
void _paintColumn(Canvas canvas, Size size, ThermerColumn column,
double yOffset, Paint linePaint) {
if (column.children.isEmpty) return;
final fixedChildren = <ThermerWidget>[];
final flexibleChildren = <ThermerWidget>[];
final fixedIndices = <int>[];
final flexibleIndices = <int>[];
final flexValues = <int>[];
for (int i = 0; i < column.children.length; i++) {
final child = column.children[i];
if (child is ThermerExpanded) {
flexibleChildren.add(child);
flexibleIndices.add(i);
flexValues.add(child.flex);
} else if (child is ThermerFlexible &&
child.fit == ThermerFlexFit.loose) {
flexibleChildren.add(child);
flexibleIndices.add(i);
flexValues.add(child.flex);
} else {
fixedChildren.add(child);
fixedIndices.add(i);
}
}
final fixedHeights = fixedChildren
.map((child) => _calculateChildHeight(
child, _calculateChildWidth(child, size.width)))
.toList();
final totalFixedHeight =
fixedHeights.isNotEmpty ? fixedHeights.reduce((a, b) => a + b) : 0;
final totalSpacing = (column.children.length - 1) * column.spacing;
final remainingHeight = size.height - totalFixedHeight - totalSpacing;
final totalFlex =
flexValues.isNotEmpty ? flexValues.reduce((a, b) => a + b) : 0;
final flexibleHeights = <double>[];
if (totalFlex > 0 && remainingHeight > 0) {
for (final flex in flexValues) {
flexibleHeights.add((flex / totalFlex) * remainingHeight);
}
} else {
flexibleHeights.addAll(List.filled(flexibleChildren.length, 0.0));
}
final actualHeights = List<double>.filled(column.children.length, 0);
for (int i = 0; i < fixedIndices.length; i++) {
actualHeights[fixedIndices[i]] = fixedHeights[i];
}
for (int i = 0; i < flexibleIndices.length; i++) {
actualHeights[flexibleIndices[i]] = flexibleHeights[i];
}
double currentY = yOffset;
final isRtl = textDirection == TextDirection.rtl;
for (int i = 0; i < column.children.length; i++) {
final child = column.children[i];
final childHeight = actualHeights[i];
final childWidth = _calculateChildWidth(child, size.width);
double childX = 0;
double effectiveChildWidth = childWidth;
if (column.crossAxisAlignment == ThermerCrossAxisAlignment.center) {
childX = (size.width - effectiveChildWidth) / 2;
} else if (column.crossAxisAlignment == ThermerCrossAxisAlignment.end) {
childX = isRtl ? 0 : size.width - effectiveChildWidth;
} else if (column.crossAxisAlignment == ThermerCrossAxisAlignment.start) {
childX = isRtl ? size.width - effectiveChildWidth : 0;
} else if (column.crossAxisAlignment ==
ThermerCrossAxisAlignment.stretch) {
effectiveChildWidth = size.width;
childX = 0;
} else {
childX = (size.width - effectiveChildWidth) / 2;
}
canvas.save();
canvas.translate(childX, 0);
_paintWidget(canvas, Size(effectiveChildWidth, childHeight), child,
currentY, linePaint);
canvas.restore();
currentY += childHeight + column.spacing;
}
}
double _calculateChildWidth(ThermerWidget child, double availableWidth) {
return LayoutUtils.calculateWidgetWidth(child, availableWidth,
defaultTextDirection: textDirection);
}
double _calculateChildHeight(ThermerWidget child, double maxWidth) {
return LayoutUtils.calculateWidgetHeight(child, maxWidth,
defaultTextDirection: textDirection);
}
void _paintDivider(Canvas canvas, Size size, ThermerDivider divider,
double yOffset, Paint linePaint) {
const dividerStyle = TextStyle(
fontSize: 20, color: Color(0xFF000000), fontWeight: FontWeight.w500);
if (divider.isHorizontal) {
final length = divider.length ??
_calculateDividerLength(divider.character, size.width, dividerStyle);
final textPainter = TextMeasurementCache.getPainter(
divider.character * length,
dividerStyle,
TextDirection.ltr,
TextAlign.left,
null,
size.width,
);
textPainter.paint(canvas, Offset(0, yOffset));
} else {
final length = divider.length ?? 1;
final textPainter = TextMeasurementCache.getPainter(
divider.character * length,
dividerStyle,
TextDirection.ltr,
TextAlign.left,
null,
size.width,
);
for (int i = 0; i < length; i++) {
textPainter.paint(canvas, Offset(0, yOffset + i * textPainter.height));
}
}
}
int _calculateDividerLength(
String character, double maxWidth, TextStyle style) {
final textPainter = TextPainter(
text: TextSpan(
text: character,
style: style,
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
if (textPainter.width == 0) return 0;
return (maxWidth / textPainter.width).floor();
}
void _paintImage(
Canvas canvas, Size size, ThermerImage imageWidget, double yOffset) {
final srcRect = Rect.fromLTWH(0, 0, imageWidget.image.width.toDouble(),
imageWidget.image.height.toDouble());
final dstRect = Rect.fromLTWH(0, yOffset, size.width, size.height);
canvas.drawImageRect(imageWidget.image, srcRect, dstRect, Paint());
}
void _paintQRCode(
Canvas canvas, Size size, ThermerQRCode qrWidget, double yOffset) {
canvas.save();
canvas.translate(0, yOffset);
final qrPainter = QrPainter(
data: qrWidget.data,
version: QrVersions.auto,
errorCorrectionLevel: qrWidget.errorCorrectionLevel,
dataModuleStyle: const QrDataModuleStyle(
color: Color(0xFF000000),
dataModuleShape: QrDataModuleShape.square,
),
eyeStyle: const QrEyeStyle(
color: Color(0xFF000000),
eyeShape: QrEyeShape.square,
),
);
qrPainter.paint(canvas, Size(qrWidget.size, qrWidget.size));
canvas.restore();
}
void _paintAlign(Canvas canvas, Size size, ThermerAlign alignWidget,
double yOffset, Paint linePaint) {
final childWidth = LayoutUtils.calculateWidgetWidth(
alignWidget.child,
size.width,
defaultTextDirection: textDirection,
);
final childHeight = LayoutUtils.calculateWidgetHeight(
alignWidget.child,
size.width,
defaultTextDirection: textDirection,
);
double xOffset = 0;
final isRtl = textDirection == TextDirection.rtl;
switch (alignWidget.alignment) {
case ThermerAlignment.left:
xOffset = isRtl ? size.width - childWidth : 0;
break;
case ThermerAlignment.center:
xOffset = (size.width - childWidth) / 2;
break;
case ThermerAlignment.right:
xOffset = isRtl ? 0 : size.width - childWidth;
break;
}
canvas.save();
canvas.translate(xOffset, 0);
_paintWidget(canvas, Size(childWidth, childHeight), alignWidget.child,
yOffset, linePaint);
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,3 @@
export '_layout_utils.dart';
export '_thermer_layout.dart';
export '_thermer_painter.dart';

View File

@@ -0,0 +1,12 @@
import '_base_widget.dart';
import '_enums.dart';
class ThermerAlign extends ThermerWidget {
final ThermerWidget child;
final ThermerAlignment alignment;
const ThermerAlign({
required this.child,
this.alignment = ThermerAlignment.center,
});
}

View File

@@ -0,0 +1,3 @@
abstract class ThermerWidget {
const ThermerWidget();
}

View File

@@ -0,0 +1,19 @@
import '_base_widget.dart';
import '_enums.dart';
class ThermerColumn extends ThermerWidget {
final List<ThermerWidget> children;
final ThermerMainAxisAlignment mainAxisAlignment;
final ThermerCrossAxisAlignment crossAxisAlignment;
final double spacing;
const ThermerColumn({
required this.children,
this.mainAxisAlignment = ThermerMainAxisAlignment.start,
this.crossAxisAlignment = ThermerCrossAxisAlignment.start,
this.spacing = 3,
});
}

View File

@@ -0,0 +1,37 @@
import '_base_widget.dart';
class ThermerDivider extends ThermerWidget {
final bool isHorizontal;
final String character;
final int? length;
ThermerDivider._({
required this.isHorizontal,
required this.character,
required this.length,
});
ThermerDivider copyWith({String? character, int? length}) {
return ThermerDivider._(
isHorizontal: isHorizontal,
character: character ?? this.character,
length: length ?? this.length,
);
}
factory ThermerDivider.horizontal({String character = '-', int? length}) {
return ThermerDivider._(
isHorizontal: true,
character: character,
length: length,
);
}
factory ThermerDivider.vertical({String character = '|', int? length}) {
return ThermerDivider._(
isHorizontal: false,
character: character,
length: length ?? 1,
);
}
}

View File

@@ -0,0 +1,5 @@
enum ThermerMainAxisAlignment { start, center, end, spaceBetween, spaceAround, spaceEvenly }
enum ThermerCrossAxisAlignment { start, center, end, stretch }
enum ThermerAlignment { left, center, right }

View File

@@ -0,0 +1,11 @@
import '_base_widget.dart';
class ThermerExpanded extends ThermerWidget {
final ThermerWidget child;
final int flex;
const ThermerExpanded({
required this.child,
this.flex = 1,
}) : assert(flex > 0, 'flex must be greater than 0');
}

View File

@@ -0,0 +1,15 @@
import '_base_widget.dart';
enum ThermerFlexFit { tight, loose }
class ThermerFlexible extends ThermerWidget {
final ThermerWidget child;
final int flex;
final ThermerFlexFit fit;
const ThermerFlexible({
required this.child,
this.flex = 1,
this.fit = ThermerFlexFit.loose,
}) : assert(flex > 0, 'flex must be greater than 0');
}

View File

@@ -0,0 +1,63 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:http/http.dart' as http;
import 'package:image/image.dart' as img;
import '_base_widget.dart';
class ThermerImage extends ThermerWidget {
final ui.Image image;
final double? width;
final double? height;
const ThermerImage({
required this.image,
this.width,
this.height,
});
static Future<ui.Image> _convertImageToUiImage(img.Image image) async {
final pngBytes = img.encodePng(image);
final codec = await ui.instantiateImageCodec(pngBytes);
final frame = await codec.getNextFrame();
return frame.image;
}
static Future<ThermerImage> network(
String url, {
double? width,
double? height,
}) async {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to load image from $url');
}
final image = img.decodeImage(response.bodyBytes);
if (image == null) {
throw Exception('Failed to decode image from $url');
}
final uiImage = await _convertImageToUiImage(image);
return ThermerImage(
image: uiImage,
width: width,
height: height,
);
}
static Future<ThermerImage> memory(
Uint8List bytes, {
double? width,
double? height,
}) async {
final image = img.decodeImage(bytes);
if (image == null) {
throw Exception('Failed to decode image from bytes');
}
final uiImage = await _convertImageToUiImage(image);
return ThermerImage(
image: uiImage,
width: width,
height: height,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:qr_flutter/qr_flutter.dart';
import '_base_widget.dart';
class ThermerQRCode extends ThermerWidget {
final String data;
final double size;
final int errorCorrectionLevel;
const ThermerQRCode({
required this.data,
this.size = 100.0,
this.errorCorrectionLevel = QrErrorCorrectLevel.L,
});
}

View File

@@ -0,0 +1,19 @@
import '_base_widget.dart';
import '_enums.dart';
class ThermerRow extends ThermerWidget {
final List<ThermerWidget> children;
final ThermerMainAxisAlignment mainAxisAlignment;
final ThermerCrossAxisAlignment crossAxisAlignment;
final double spacing;
const ThermerRow({
required this.children,
this.mainAxisAlignment = ThermerMainAxisAlignment.start,
this.crossAxisAlignment = ThermerCrossAxisAlignment.center,
this.spacing = 0,
});
}

View File

@@ -0,0 +1,17 @@
import '_base_widget.dart';
class ThermerSizedBox extends ThermerWidget {
final double? width;
final double? height;
final ThermerWidget? child;
const ThermerSizedBox({this.width, this.height, this.child});
const ThermerSizedBox.square({
double dimension = 0,
this.child,
}) : width = dimension,
height = dimension;
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '_base_widget.dart';
class ThermerTableRow {
final List<ThermerWidget> cells;
const ThermerTableRow(this.cells);
}
class ThermerTable extends ThermerWidget {
final List<ThermerTableRow> data;
final ThermerTableRow? header;
final Map<int, double?>? cellWidths;
final double columnSpacing;
final double rowSpacing;
final TextStyle? style;
final TextStyle? headerStyle;
final String horizontalBorderChar;
final String verticalBorderChar;
final bool enableHeaderBorders;
final bool enableTableBorders;
const ThermerTable({
required this.data,
this.header,
this.cellWidths,
this.columnSpacing = 10.0,
this.rowSpacing = 3.0,
this.style,
this.headerStyle,
this.horizontalBorderChar = '-',
this.verticalBorderChar = '|',
this.enableHeaderBorders = true,
this.enableTableBorders = false,
});
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '_base_widget.dart';
class ThermerText extends ThermerWidget {
ThermerText(
this.data, {
this.direction,
this.style = const TextStyle(color: Color(0xFF000000), fontWeight: FontWeight.w500),
this.textAlign = TextAlign.left,
this.maxLines,
this.fallbackFonts,
});
final String data;
final TextDirection? direction;
final TextStyle? style;
final TextAlign textAlign;
final int? maxLines;
final List<String>? fallbackFonts;
}

View File

@@ -0,0 +1,13 @@
export '_align.dart';
export '_base_widget.dart';
export '_column.dart';
export '_divider.dart';
export '_enums.dart';
export '_expanded.dart';
export '_flexible.dart';
export '_image.dart';
export '_qr_code.dart';
export '_row.dart';
export '_sized_box.dart';
export '_table.dart';
export '_text.dart';

View File

@@ -0,0 +1,6 @@
library;
export 'src/layouts/layouts.export.dart';
export 'src/widgets/widgets.export.dart';
export 'package:flutter/material.dart'
show TextStyle, Color, Colors, TextAlign, FontWeight, TextDecoration, TextDirection;