chore: initial commit Dutra Store POS app
This commit is contained in:
167
lib/services/export_service.dart
Normal file
167
lib/services/export_service.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class ExportService {
|
||||
static final ExportService _instance = ExportService._internal();
|
||||
factory ExportService() => _instance;
|
||||
ExportService._internal();
|
||||
|
||||
Future<String?> exportToPdf(List<Map<String, dynamic>> transactions,
|
||||
String storeName, DateTimeRange? dateRange) async {
|
||||
try {
|
||||
debugPrint('exportToPdf called with ${transactions.length} transactions');
|
||||
final pdf = pw.Document();
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
build: (context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(storeName, style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 8),
|
||||
if (dateRange != null)
|
||||
pw.Text('${_formatDate(dateRange.start)} - ${_formatDate(dateRange.end)}',
|
||||
style: pw.TextStyle(fontSize: 12)),
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(),
|
||||
children: [
|
||||
pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Tanggal', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('ID', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Bayar', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Kembali', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
||||
],
|
||||
),
|
||||
for (final tx in transactions)
|
||||
pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(_formatDate(tx['date']))),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['id'])),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['total'])),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['paid'])),
|
||||
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['change'])),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Directory? directory;
|
||||
try {
|
||||
directory = await getTemporaryDirectory();
|
||||
debugPrint('PDF using temporary directory: ${directory.path}');
|
||||
} catch (e) {
|
||||
debugPrint('getTemporaryDirectory failed for PDF: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = File('${directory.path}/laporan_transaksi_${DateTime.now().millisecondsSinceEpoch}.pdf');
|
||||
try {
|
||||
await file.writeAsBytes(await pdf.save());
|
||||
debugPrint('PDF file written: ${file.path}, exists: ${await file.exists()}');
|
||||
} catch (e) {
|
||||
debugPrint('Failed to write PDF file: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
return file.path;
|
||||
} catch (e) {
|
||||
debugPrint('exportToPdf error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> exportToCsv(List<Map<String, dynamic>> transactions) async {
|
||||
try {
|
||||
debugPrint('=== CSV Export Started ===');
|
||||
debugPrint('Transactions count: ${transactions.length}');
|
||||
|
||||
if (transactions.isEmpty) {
|
||||
debugPrint('No transactions to export');
|
||||
return null;
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Tanggal,ID Transaksi,Total,Bayar,Kembali,Item,Diskon');
|
||||
|
||||
for (final tx in transactions) {
|
||||
final date = _formatDate(tx['date']);
|
||||
final id = tx['id'];
|
||||
final total = tx['total'];
|
||||
final paid = tx['paid'];
|
||||
final change = tx['change'];
|
||||
final items = tx['items'] is List ? (tx['items'] as List).length : (tx['items'] as int? ?? 0);
|
||||
final discount = tx['discount'] ?? 0;
|
||||
|
||||
buffer.writeln('$date,$id,$total,$paid,$change,$items,$discount');
|
||||
}
|
||||
|
||||
debugPrint('CSV buffer created, length: ${buffer.toString().length}');
|
||||
|
||||
String? filePath;
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
debugPrint('Temp dir: ${tempDir.path}');
|
||||
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final fileName = 'laporan_transaksi_$timestamp.csv';
|
||||
filePath = '${tempDir.path}/$fileName';
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsString(buffer.toString());
|
||||
debugPrint('CSV file written: $filePath');
|
||||
debugPrint('File exists: ${await file.exists()}');
|
||||
debugPrint('File size: ${await file.length()}');
|
||||
} catch (e) {
|
||||
debugPrint('Error creating CSV file: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
debugPrint('=== CSV Export Completed Successfully ===');
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
debugPrint('=== CSV Export Failed ===');
|
||||
debugPrint('Error: $e');
|
||||
debugPrint('Stack trace: ${StackTrace.current}');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shareFile(String filePath) async {
|
||||
try {
|
||||
debugPrint('Sharing file: $filePath');
|
||||
final file = File(filePath);
|
||||
final exists = await file.exists();
|
||||
debugPrint('File exists: $exists, size: ${exists ? await file.length() : 0}');
|
||||
|
||||
if (!exists) {
|
||||
debugPrint('File does not exist, cannot share');
|
||||
return;
|
||||
}
|
||||
|
||||
await Share.shareXFiles([XFile(filePath)], text: 'Laporan Transaksi');
|
||||
debugPrint('Share completed');
|
||||
} catch (e) {
|
||||
debugPrint('shareFile error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(dynamic date) {
|
||||
if (date is DateTime) {
|
||||
return '${date.day}/${date.month}/${date.year}';
|
||||
}
|
||||
return date.toString();
|
||||
}
|
||||
}
|
||||
156
lib/services/offline_queue_service.dart
Normal file
156
lib/services/offline_queue_service.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
class OfflineQueueService {
|
||||
static final OfflineQueueService _instance = OfflineQueueService._internal();
|
||||
factory OfflineQueueService() => _instance;
|
||||
OfflineQueueService._internal();
|
||||
|
||||
final SupabaseClient _supabase = Supabase.instance.client;
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
List<Map<String, dynamic>> _queue = [];
|
||||
bool _isOnline = true;
|
||||
bool _syncing = false;
|
||||
|
||||
static const Duration _timeout = Duration(seconds: 30);
|
||||
|
||||
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
|
||||
try {
|
||||
return await future.timeout(
|
||||
_timeout,
|
||||
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
|
||||
);
|
||||
} on TimeoutException catch (e) {
|
||||
print('OfflineQueue timeout: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get queue => List.unmodifiable(_queue);
|
||||
bool get isOnline => _isOnline;
|
||||
bool get hasPendingItems => _queue.isNotEmpty;
|
||||
|
||||
Future<void> init() async {
|
||||
await _loadQueue();
|
||||
_connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
|
||||
await _checkConnection();
|
||||
if (_isOnline) await _processQueue();
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final queueJson = prefs.getString('offline_queue');
|
||||
if (queueJson != null) {
|
||||
final List<dynamic> decoded = json.decode(queueJson);
|
||||
_queue = decoded.cast<Map<String, dynamic>>();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveQueue() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('offline_queue', json.encode(_queue));
|
||||
}
|
||||
|
||||
Future<void> _checkConnection() async {
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
_updateConnectionStatus(result);
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(List<ConnectivityResult> result) {
|
||||
_isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (_isOnline && _queue.isNotEmpty) {
|
||||
_processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addToQueue(Map<String, dynamic> transaction) async {
|
||||
_queue.add(transaction);
|
||||
await _saveQueue();
|
||||
if (_isOnline) {
|
||||
await _processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
if (_syncing || !_isOnline || _queue.isEmpty) return;
|
||||
_syncing = true;
|
||||
|
||||
while (_queue.isNotEmpty && _isOnline) {
|
||||
final item = _queue.first;
|
||||
try {
|
||||
await _processTransaction(item);
|
||||
_queue.removeAt(0);
|
||||
await _saveQueue();
|
||||
} catch (e) {
|
||||
_syncing = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
_syncing = false;
|
||||
}
|
||||
|
||||
Future<void> _processTransaction(Map<String, dynamic> transaction) async {
|
||||
final sale = transaction['sale'] as Map<String, dynamic>;
|
||||
final items = (transaction['items'] as List?) ?? [];
|
||||
|
||||
final saleId = sale['id'] as String;
|
||||
final existing = await _withTimeout(
|
||||
_supabase
|
||||
.from('sales')
|
||||
.select('id, total_cost')
|
||||
.eq('id', saleId)
|
||||
.maybeSingle(),
|
||||
'Check existing sale',
|
||||
);
|
||||
|
||||
if (existing == null) {
|
||||
await _withTimeout(
|
||||
_supabase.from('sales').insert(sale),
|
||||
'Insert queued sale',
|
||||
);
|
||||
} else {
|
||||
final existingTotalCost = (existing['total_cost'] as num?)?.toDouble() ?? 0;
|
||||
if (existingTotalCost <= 0 && sale['total_cost'] != null) {
|
||||
await _withTimeout(
|
||||
_supabase
|
||||
.from('sales')
|
||||
.update({'total_cost': sale['total_cost']})
|
||||
.eq('id', saleId),
|
||||
'Update sale total_cost',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final it in items) {
|
||||
final productId = it['product_id'] as String;
|
||||
final qty = (it['qty'] as num).toInt();
|
||||
final product = await _withTimeout(
|
||||
_supabase
|
||||
.from('products')
|
||||
.select()
|
||||
.eq('id', productId)
|
||||
.maybeSingle(),
|
||||
'Load product for stock update',
|
||||
);
|
||||
if (product != null) {
|
||||
final currentStock = (product['stock'] as num).toInt();
|
||||
final newStock = (currentStock - qty).clamp(0, 1 << 62);
|
||||
await _withTimeout(
|
||||
_supabase
|
||||
.from('products')
|
||||
.update({'stock': newStock})
|
||||
.eq('id', productId),
|
||||
'Update product stock',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearQueue() async {
|
||||
_queue.clear();
|
||||
await _saveQueue();
|
||||
}
|
||||
}
|
||||
223
lib/services/printer_service.dart
Normal file
223
lib/services/printer_service.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
|
||||
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/sale.dart';
|
||||
import '../utils/format.dart';
|
||||
|
||||
class PrinterService {
|
||||
static const String _printerKey = 'selected_printer_mac';
|
||||
|
||||
String? _connectedMac;
|
||||
bool _isConnected = false;
|
||||
|
||||
String? get connectedMac => _connectedMac;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_connectedMac = prefs.getString(_printerKey);
|
||||
if (_connectedMac != null) {
|
||||
await connect(_connectedMac!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isBluetoothEnabled() async {
|
||||
return PrintBluetoothThermal.bluetoothEnabled;
|
||||
}
|
||||
|
||||
Future<List<BluetoothInfo>> getPairedDevices() async {
|
||||
final list = await PrintBluetoothThermal.pairedBluetooths;
|
||||
return list;
|
||||
}
|
||||
|
||||
Future<bool> connect(String mac) async {
|
||||
try {
|
||||
final connected = await PrintBluetoothThermal.connect(macPrinterAddress: mac);
|
||||
_isConnected = connected;
|
||||
if (connected) {
|
||||
_connectedMac = mac;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_printerKey, mac);
|
||||
}
|
||||
return connected;
|
||||
} catch (_) {
|
||||
_isConnected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
try {
|
||||
await PrintBluetoothThermal.disconnect;
|
||||
} catch (_) {}
|
||||
_isConnected = false;
|
||||
}
|
||||
|
||||
Future<void> forgetPrinter() async {
|
||||
_connectedMac = null;
|
||||
_isConnected = false;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_printerKey);
|
||||
}
|
||||
|
||||
Future<List<int>> _buildReceiptBytes(Sale sale, String storeName, {String? kasirName, String? whatsapp, String? notes}) async {
|
||||
final profile = await CapabilityProfile.load();
|
||||
final generator = Generator(PaperSize.mm58, profile);
|
||||
final bytes = <int>[];
|
||||
|
||||
bytes.addAll(generator.text(
|
||||
storeName,
|
||||
styles: const PosStyles(
|
||||
align: PosAlign.center,
|
||||
bold: true,
|
||||
height: PosTextSize.size2,
|
||||
),
|
||||
));
|
||||
bytes.addAll(generator.text(
|
||||
formatTanggal(sale.date),
|
||||
styles: const PosStyles(align: PosAlign.center),
|
||||
));
|
||||
bytes.addAll(generator.text(
|
||||
formatWaktu(sale.date),
|
||||
styles: const PosStyles(align: PosAlign.center),
|
||||
));
|
||||
bytes.addAll(generator.text(
|
||||
'#${sale.id.toUpperCase()}',
|
||||
styles: const PosStyles(align: PosAlign.center),
|
||||
));
|
||||
bytes.addAll(generator.hr());
|
||||
|
||||
for (final it in sale.items) {
|
||||
bytes.addAll(generator.text('${it.name} x${it.qty}',
|
||||
styles: const PosStyles(bold: true)));
|
||||
bytes.addAll(generator.row([
|
||||
PosColumn(
|
||||
text: formatRupiah(it.price),
|
||||
width: 6,
|
||||
),
|
||||
PosColumn(
|
||||
text: formatRupiah(it.subtotal),
|
||||
width: 6,
|
||||
styles: const PosStyles(align: PosAlign.right),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
bytes.addAll(generator.hr());
|
||||
if (sale.discount > 0) {
|
||||
bytes.addAll(generator.row([
|
||||
PosColumn(text: 'Diskon', width: 6),
|
||||
PosColumn(
|
||||
text: '-${formatRupiah(sale.discount)}',
|
||||
width: 6,
|
||||
styles: const PosStyles(align: PosAlign.right),
|
||||
),
|
||||
]));
|
||||
}
|
||||
bytes.addAll(generator.row([
|
||||
PosColumn(
|
||||
text: 'TOTAL',
|
||||
width: 6,
|
||||
styles: const PosStyles(bold: true),
|
||||
),
|
||||
PosColumn(
|
||||
text: formatRupiah(sale.total),
|
||||
width: 6,
|
||||
styles: const PosStyles(bold: true, align: PosAlign.right),
|
||||
),
|
||||
]));
|
||||
final methodLabel = sale.paymentMethod == 'transfer'
|
||||
? 'Bank'
|
||||
: sale.paymentMethod == 'qris'
|
||||
? 'QRIS'
|
||||
: sale.paymentMethod == 'split'
|
||||
? 'Split Payment'
|
||||
: 'Tunai';
|
||||
bytes.addAll(generator.row([
|
||||
PosColumn(text: 'Metode', width: 6),
|
||||
PosColumn(
|
||||
text: methodLabel,
|
||||
width: 6,
|
||||
styles: const PosStyles(align: PosAlign.right),
|
||||
),
|
||||
]));
|
||||
if (sale.paymentMethod == 'tunai') {
|
||||
bytes.addAll(generator.row([
|
||||
PosColumn(text: 'Bayar', width: 6),
|
||||
PosColumn(
|
||||
text: formatRupiah(sale.paid),
|
||||
width: 6,
|
||||
styles: const PosStyles(align: PosAlign.right),
|
||||
),
|
||||
]));
|
||||
bytes.addAll(generator.row([
|
||||
PosColumn(text: 'Kembali', width: 6),
|
||||
PosColumn(
|
||||
text: formatRupiah(sale.change),
|
||||
width: 6,
|
||||
styles: const PosStyles(align: PosAlign.right),
|
||||
),
|
||||
]));
|
||||
}
|
||||
if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty) {
|
||||
for (final entry in sale.splitPayment!.entries) {
|
||||
final label = entry.key == 'tunai'
|
||||
? 'Tunai'
|
||||
: entry.key == 'transfer'
|
||||
? 'Transfer'
|
||||
: 'QRIS';
|
||||
bytes.addAll(generator.row([
|
||||
PosColumn(text: 'Split $label', width: 6),
|
||||
PosColumn(
|
||||
text: formatRupiah(entry.value),
|
||||
width: 6,
|
||||
styles: const PosStyles(align: PosAlign.right),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
bytes.addAll(generator.hr());
|
||||
if (kasirName != null && kasirName.isNotEmpty) {
|
||||
bytes.addAll(generator.text('Kasir: $kasirName',
|
||||
styles: const PosStyles(align: PosAlign.center)));
|
||||
}
|
||||
if (whatsapp != null && whatsapp.isNotEmpty) {
|
||||
bytes.addAll(generator.text('WA: $whatsapp',
|
||||
styles: const PosStyles(align: PosAlign.center)));
|
||||
}
|
||||
if (notes != null && notes.trim().isNotEmpty) {
|
||||
final noteLines = notes.trim().split('\n');
|
||||
for (final line in noteLines) {
|
||||
final text = line.trim();
|
||||
if (text.isEmpty) continue;
|
||||
bytes.addAll(generator.text(text,
|
||||
styles: const PosStyles(align: PosAlign.center)));
|
||||
}
|
||||
}
|
||||
bytes.addAll(generator.hr());
|
||||
bytes.addAll(generator.text(
|
||||
'Terima kasih!',
|
||||
styles: const PosStyles(align: PosAlign.center),
|
||||
));
|
||||
bytes.addAll(generator.feed(2));
|
||||
bytes.addAll(generator.cut());
|
||||
return bytes;
|
||||
}
|
||||
|
||||
Future<String?> printSale(Sale sale, String storeName, {String? kasirName, String? whatsapp, String? notes}) async {
|
||||
if (!_isConnected || _connectedMac == null) {
|
||||
return 'Printer belum terhubung';
|
||||
}
|
||||
try {
|
||||
final bytes = await _buildReceiptBytes(sale, storeName, kasirName: kasirName, whatsapp: whatsapp, notes: notes);
|
||||
final result = await PrintBluetoothThermal.writeBytes(bytes);
|
||||
if (result) return null;
|
||||
return 'Gagal mengirim ke printer';
|
||||
} catch (e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final printerService = PrinterService();
|
||||
Reference in New Issue
Block a user