fix: cetak semua station tanpa saling membatalkan

printStruckOrder berhenti di kegagalan pertama, jadi satu station gagal membuat semua station sesudahnya tidak pernah dicoba. Karena checker (Bluetooth) berada sebelum kitchen dan bar (Network) dalam rantai, satu kegagalan koneksi Bluetooth menghilangkan tiga tiket sekaligus, dua di antaranya di jaringan yang sehat.

Jalur printer Network juga mengembalikan right(false) saat gagal, yang lolos sebagai sukses ke pemanggil, sehingga kasir melihat 'berhasil dicetak' padahal tidak ada kertas keluar.

Perubahan: repository kini mengembalikan Stream<List<PrintStationResult>> dan tetap mencoba semua station; kegagalan Network jadi left; station Network berjalan paralel dengan rantai Bluetooth; station Bluetooth dikelompokkan per alamat agar perpindahan koneksi seminimal mungkin; CapabilityProfile di-cache.

Bluetooth: retry connect dengan backoff dan disconnect di tiap percobaan ulang (socket RFCOMM setengah terbuka membuat connect berulang gagal identik), jeda settle dikembalikan setelah dipangkas ke 300ms, satu lock untuk seluruh radio (bukan per MAC), izin runtime dipastikan di jalur cetak, dan repository jadi LazySingleton agar state koneksi benar-benar dibagi.

UI: dialog progres per-station menggantikan toast tunggal, lengkap dengan durasi tiap station dan tombol cetak ulang khusus yang gagal. Enam listener duplikat di halaman diganti satu listener terpusat di AppWidget. Menu Print di detail transaksi dapat item Print All.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-02 20:44:14 +07:00
co-authored by Claude Opus 5
parent 038f4d65fe
commit 1b4caf45d5
21 changed files with 1963 additions and 1403 deletions
@@ -0,0 +1,273 @@
part of 'dialog.dart';
/// Dijaga supaya satu job cetak hanya membuka satu dialog, walau listener
/// terpicu lebih dari sekali.
bool _isPrintProgressDialogOpen = false;
Future<void> showPrintProgressDialog(BuildContext context) async {
if (_isPrintProgressDialogOpen) return;
_isPrintProgressDialogOpen = true;
try {
await showDialog<void>(
context: context,
// Cetak jalan di latar belakang, kasir tidak perlu menunggui dialognya.
barrierDismissible: true,
builder: (_) => const PrintProgressDialog(),
);
} finally {
_isPrintProgressDialogOpen = false;
}
}
class PrintProgressDialog extends StatefulWidget {
const PrintProgressDialog({super.key});
@override
State<PrintProgressDialog> createState() => _PrintProgressDialogState();
}
class _PrintProgressDialogState extends State<PrintProgressDialog> {
Timer? _autoClose;
@override
void dispose() {
_autoClose?.cancel();
super.dispose();
}
/// Semua station keluar — tutup sendiri supaya antrean kasir tidak tertahan,
/// tapi beri jeda sebentar agar centang hijaunya sempat terbaca.
void _scheduleAutoClose() {
_autoClose?.cancel();
_autoClose = Timer(const Duration(milliseconds: 1500), () {
if (mounted) Navigator.of(context).pop();
});
}
@override
Widget build(BuildContext context) {
return BlocConsumer<PrintStruckBloc, PrintStruckState>(
listenWhen: (previous, current) =>
previous.isPrinting && !current.isPrinting,
listener: (context, state) {
if (state.hasFailure) return;
_scheduleAutoClose();
},
builder: (context, state) {
final isDone = !state.isPrinting;
return CustomModalDialog(
title: isDone ? 'Hasil Cetak' : 'Mencetak Struk',
maxWidth: 480,
minHeight: 0,
contentPadding: const EdgeInsets.all(20),
bottom: _buildFooter(context, state),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSummary(state),
const SpaceHeight(16),
for (final result in state.stations) ...[
_StationRow(result: result),
const SpaceHeight(8),
],
],
),
);
},
);
}
Widget _buildSummary(PrintStruckState state) {
final isDone = !state.isPrinting;
final total = state.plannedCount;
final success = state.successCount;
final Color color;
final String message;
if (!isDone) {
color = AppColor.primary;
message = 'Mengirim ke printer…';
} else if (state.hasFailure) {
color = AppColor.error;
message = '${state.failedStations.length} station gagal dicetak';
} else {
color = AppColor.success;
message = 'Semua struk berhasil dicetak';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Row(
children: [
Expanded(
child: Text(
message,
style: AppStyle.md.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
),
Text(
'$success dari $total',
style: AppStyle.md.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildFooter(BuildContext context, PrintStruckState state) {
final isDone = !state.isPrinting;
return Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isDone) ...[
Text(
'Cetak berjalan di latar belakang. Dialog ini boleh ditutup.',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
),
const SpaceHeight(12),
],
Row(
children: [
Expanded(
child: AppElevatedButton.outlined(
onPressed: () => Navigator.of(context).pop(),
label: 'Tutup',
),
),
if (isDone && state.hasFailure) ...[
const SpaceWidth(12),
Expanded(
flex: 2,
child: AppElevatedButton.filled(
onPressed: () => context.read<PrintStruckBloc>().add(
const PrintStruckEvent.retryFailed(),
),
label: 'Cetak Ulang yang Gagal',
icon: const Icon(
Icons.refresh_rounded,
color: AppColor.white,
size: 18,
),
),
),
],
],
),
],
),
);
}
}
class _StationRow extends StatelessWidget {
final PrintStationResult result;
const _StationRow({required this.result});
@override
Widget build(BuildContext context) {
final color = _color;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: result.isFailed ? color.withOpacity(0.4) : AppColor.border,
),
color: result.isFailed ? color.withOpacity(0.05) : Colors.transparent,
),
child: Row(
children: [
SizedBox(width: 20, height: 20, child: Center(child: _icon)),
const SpaceWidth(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.station.label,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
const SpaceHeight(2),
Text(
_statusText,
style: AppStyle.sm.copyWith(color: color),
),
],
),
),
if (result.elapsed > Duration.zero) ...[
const SpaceWidth(8),
Text(
'${(result.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
],
),
);
}
Widget get _icon => switch (result.status) {
PrintStationStatus.printing => const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
PrintStationStatus.pending => const Icon(
Icons.schedule_rounded,
size: 18,
color: AppColor.textSecondary,
),
PrintStationStatus.success => const Icon(
Icons.check_circle_rounded,
size: 20,
color: AppColor.success,
),
PrintStationStatus.failed => const Icon(
Icons.error_rounded,
size: 20,
color: AppColor.error,
),
PrintStationStatus.skipped => const Icon(
Icons.remove_circle_outline_rounded,
size: 18,
color: AppColor.textSecondary,
),
};
Color get _color => switch (result.status) {
PrintStationStatus.printing => AppColor.primary,
PrintStationStatus.success => AppColor.success,
PrintStationStatus.failed => AppColor.error,
PrintStationStatus.pending ||
PrintStationStatus.skipped => AppColor.textSecondary,
};
String get _statusText => switch (result.status) {
PrintStationStatus.pending => 'Menunggu giliran',
PrintStationStatus.printing => 'Mengirim ke printer…',
PrintStationStatus.success => 'Berhasil dicetak',
PrintStationStatus.failed => result.message ?? 'Gagal dicetak',
PrintStationStatus.skipped => result.message ?? 'Dilewati',
};
}