chore: initial commit Dutra Store POS app

This commit is contained in:
Onie
2026-07-23 22:12:32 +07:00
commit 22356f630e
175 changed files with 13934 additions and 0 deletions

View 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();
}
}

View 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();
}
}

View 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();