Notifikasi transaksi: hanya untuk admin + transaksi dari orang lain (bukan transaksi sendiri)

This commit is contained in:
Onie
2026-10-18 03:06:23 +07:00
parent ab26487882
commit 808992f9bd
2 changed files with 83 additions and 0 deletions

View File

@@ -1,13 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'config.dart'; import 'config.dart';
import 'models/sale.dart';
import 'state/app_state.dart'; import 'state/app_state.dart';
import 'services/printer_service.dart'; import 'services/printer_service.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/home_page.dart'; import 'screens/home_page.dart';
import 'utils/colors.dart'; import 'utils/colors.dart';
import 'utils/format.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -25,12 +28,48 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> with WidgetsBindingObserver { class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
late final AppState _appState; late final AppState _appState;
final GlobalKey<ScaffoldMessengerState> _scaffoldKey =
GlobalKey<ScaffoldMessengerState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_appState = AppState(); _appState = AppState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
// Dengarkan stream notifikasi transaksi baru (dari HP lain / checkout).
_appState.newSaleNotifications.stream.listen(_showSaleNotification);
}
void _showSaleNotification(Sale sale) {
final messenger = _scaffoldKey.currentState;
if (messenger == null) return;
// Getar + bunyi notifikasi.
HapticFeedback.heavyImpact();
SystemSound.play(SystemSoundType.click);
// Banner notifikasi di atas layar.
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: kSuccess, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Transaksi baru: ${sale.items.fold<int>(0, (s, i) => s + i.qty)} item • ${formatRupiah(sale.total)}',
style: const TextStyle(color: kText, fontWeight: FontWeight.w600),
),
),
],
),
backgroundColor: kSurface,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: kSuccess, width: 1),
),
),
);
} }
@override @override
@@ -54,6 +93,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: _appState, value: _appState,
child: MaterialApp( child: MaterialApp(
scaffoldMessengerKey: _scaffoldKey,
title: 'Dutra Store', title: 'Dutra Store',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(

View File

@@ -27,6 +27,12 @@ class CartLine {
class AppState extends ChangeNotifier { class AppState extends ChangeNotifier {
final SupabaseClient _supabase = Supabase.instance.client; final SupabaseClient _supabase = Supabase.instance.client;
// Stream notifikasi transaksi baru (dipakai UI untuk banner+suara+getar).
// Diisi setiap createSale sukses DAN setiap realtime INSERT dari HP lain.
final StreamController<Sale> newSaleNotifications =
StreamController<Sale>.broadcast();
final Set<String> _notifiedSaleIds = {};
// Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir). // Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir).
// PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya. // PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya.
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey); final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
@@ -135,6 +141,38 @@ class AppState extends ChangeNotifier {
if (elapsed < const Duration(seconds: 15)) return; if (elapsed < const Duration(seconds: 15)) return;
} }
await refresh(); await refresh();
_checkNewSales();
}
bool _salesFirstLoad = true;
/// Deteksi transaksi baru dari polling/refresh dan kirim ke stream
/// notifikasi. Load pertama dilewati agar tidak spam saat start.
void _checkNewSales() {
// Hanya admin yang perlu notifikasi — kasir tidak (mereka yang
// transaksi, jadi sudah tahu).
if (userRole != 'admin') {
for (final s in sales) {
_notifiedSaleIds.add(s.id);
}
_salesFirstLoad = false;
return;
}
final now = DateTime.now();
for (final s in sales) {
if (!_notifiedSaleIds.contains(s.id)) {
_notifiedSaleIds.add(s.id);
// Lewati transaksi lama (>5 menit) agar tidak spam notif saat start.
// Lewati juga transaksi sendiri — admin tahu kalau dia yang jualan.
if (!_salesFirstLoad &&
s.userId != userId &&
now.difference(s.date).inMinutes.abs() <= 5 &&
!newSaleNotifications.isClosed) {
newSaleNotifications.add(s);
}
}
}
_salesFirstLoad = false;
} }
Future<void> refresh() async { Future<void> refresh() async {
@@ -686,6 +724,11 @@ class AppState extends ChangeNotifier {
cart.clear(); cart.clear();
_discount = 0; _discount = 0;
notifyListeners(); notifyListeners();
// Tandai ID transaksi lokal agar tidak di-notify lagi via polling
// (admin yang jualan sendiri tidak perlu notif transaksinya sendiri).
_notifiedSaleIds.add(sale.id);
return sale; return sale;
} catch (e) { } catch (e) {
await _offlineQueue.addToQueue({ await _offlineQueue.addToQueue({