21



// =================================================================================
// FINAL CODE - Uses AudioTools (A2DP) + Bluetooth Serial (SPP) + MD_MAX72xx Display
// =================================================================================
//ESP32 Pin PCM5100A Module Pin
//GPIO 26 BCK (BCLK)
//GPIO 25 I2S LRC (word select)
//GPIO 22 DIN (DATA)
//5V VCC (Power input)
//GND GND
//SPK+ / SPK- Connect to 4Ω speaker (Left)
//SPK+ / SPK- Connect to 4Ω speaker (Right)
#include <MD_MAX72xx.h>
#include "AudioTools.h"
#include "BluetoothA2DPSink.h"
#include "BluetoothSerial.h"
// --- Display Settings ---
#define DEBUG 1
#if DEBUG
#define PRINT(s, x) \
{ \
Serial.print(F(s)); \
Serial.print(x); \
}
#define PRINTS(x) Serial.print(F(x))
#define PRINTD(x) Serial.println(x)
#else
#define PRINT(s, x)
#define PRINTS(x)
#define PRINTD(x)
#endif
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW
#define MAX_DEVICES 8
#define CLK_PIN 18
#define DATA_PIN 23
#define CS_PIN 5
MD_MAX72XX mx = MD_MAX72XX(HARDWARE_TYPE, DATA_PIN, CLK_PIN, CS_PIN, MAX_DEVICES);
// --- Bluetooth Settings ---
I2SStream out;
BluetoothA2DPSink a2dp_sink(out);
BluetoothSerial SerialBT;
// ====================================================================
// DISPLAY FUNCTION
// ====================================================================
void displayText(const char *p)
{
mx.clear();
uint16_t logical_col = 0;
while (*p != '\0')
{
uint8_t cBuf[8];
uint8_t charWidth = mx.getChar(*p, sizeof(cBuf), cBuf);
if (logical_col + charWidth > mx.getColumnCount())
{
break;
}
uint16_t physical_pos = (mx.getColumnCount() - 1) - logical_col;
mx.setChar(physical_pos, *p);
logical_col += charWidth + 1;
p++;
}
}
// ====================================================================
// SETUP FUNCTION
// ====================================================================
void setup()
{
#if DEBUG
Serial.begin(115200);
#endif
PRINTS("\n[MD_MAX72XX + BT Speaker/Display Demo]");
// --- Initialize Display ---
if (!mx.begin())
PRINTS("\nMD_MAX72XX initialization failed");
mx.control(MD_MAX72XX::INTENSITY, 5);
mx.clear();
displayText("WELCOME");
delay(2000);
mx.clear();
// --- Configure I2S Audio Output ---
PRINTS("\nConfiguring I2S...");
auto cfg = out.defaultConfig();
cfg.pin_bck = 26;
cfg.pin_ws = 25;
cfg.pin_data = 22;
out.begin(cfg);
PRINTS("\nI2S Configured.");
// --- Initialize Bluetooth Services ---
a2dp_sink.start("NOBLE-BT");
PRINTS("\nBluetooth Audio 'NOBLE-BT' Initialized.");
SerialBT.begin("NOBLE-DISPLAY");
PRINTS("\nBluetooth Serial 'NOBLE-DISPLAY' Initialized. Ready for data.");
displayText("READY");
}
// ====================================================================
// MAIN LOOP
// ====================================================================
void loop()
{
if (SerialBT.available())
{
String receivedText = SerialBT.readStringUntil('\n');
receivedText.trim();
if (receivedText.length() > 0)
{
PRINTS("\nReceived: ");
PRINTD(receivedText);
displayText(receivedText.c_str());
}
}
}
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:nms_billing/announcement_screen.dart';
import 'package:nms_billing/announcement_service.dart';
import 'package:nms_billing/firestore_service.dart';
import 'package:nms_billing/service_locator.dart';
import 'dart:async';
import 'package:nms_billing/bluetooth_service.dart';
class TokenGeneratorScreen extends StatefulWidget {
const TokenGeneratorScreen({super.key});
@override
State<TokenGeneratorScreen> createState() => _TokenGeneratorScreenState();
}
class _TokenGeneratorScreenState extends State<TokenGeneratorScreen> {
final _formKey = GlobalKey<FormState>();
final _vehicleNoController = TextEditingController();
final _contactNoController = TextEditingController();
final _firestoreService = FirestoreService();
final _announcementService = sl<AnnouncementService>();
final _bluetoothService = BluetoothService();
String? _generatedToken;
bool _isLoading = true;
bool _isBtConnected = false;
bool _isConnecting = false;
@override
void initState() {
super.initState();
_loadPendingToken();
Future.delayed(const Duration(seconds: 1), _connectToDisplay);
}
@override
void dispose() {
_bluetoothService.disconnect();
_vehicleNoController.dispose();
_contactNoController.dispose();
super.dispose();
}
Future<void> _connectToDisplay() async {
if (_isBtConnected || _isConnecting) return;
setState(() => _isConnecting = true);
bool success = await _bluetoothService.connect();
if (mounted) {
setState(() {
_isBtConnected = success;
_isConnecting = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? 'Connected to Display' : 'Failed to connect. Please pair device first.'),
backgroundColor: success ? Colors.green : Colors.red,
// --- CHANGE 1: Make SnackBar float ---
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _loadPendingToken() async {
setState(() { _isLoading = true; });
try {
String token = await _firestoreService.peekNextTokenNumber();
if (mounted) {
setState(() {
_generatedToken = token;
_isLoading = false;
});
}
} catch (e) {
print("Error loading pending token: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Error: $e'),
// --- CHANGE 2: Make SnackBar float and add error color ---
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
));
setState(() => _isLoading = false);
}
}
}
Future<void> _saveToken() async {
if (_formKey.currentState!.validate()) {
setState(() { _isLoading = true; });
try {
final vehicleNumber = 'KL-${_vehicleNoController.text.toUpperCase()}';
final tokenData = {
'tokenNumber': _generatedToken,
'vehicleNo': vehicleNumber,
'contactNo': _contactNoController.text,
'timestamp': DateTime.now().toIso8601String(),
'status': 0, // 0: Pending
'paymentStatus': 'UNPAID',
};
await _firestoreService.saveToken(tokenData);
await _firestoreService.incrementTokenCounter();
_announcementService.addAnnouncement(
token: _generatedToken!,
vehicle: vehicleNumber,
);
if (mounted) {
if (_isBtConnected) {
final String tokenToDisplay = "NMS-TOK-${_generatedToken!}";
await _bluetoothService.sendToken(tokenToDisplay);
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const AnnouncementScreen()),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Token Generated Successfully!'),
backgroundColor: Colors.green,
// --- CHANGE 3: Make SnackBar float ---
behavior: SnackBarBehavior.floating,
));
_vehicleNoController.clear();
_contactNoController.clear();
await _loadPendingToken();
}
}
} catch (e) {
print("Error saving token: $e");
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Error saving token: $e'),
// --- CHANGE 4: Make SnackBar float and add error color ---
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
));
setState(() => _isLoading = false);
}
}
}
}
// --- NO CHANGES NEEDED IN THE UI BUILDER METHODS ---
@override
Widget build(BuildContext context) {
String currentDate = DateFormat('dd-MM-yyyy HH:mm').format(DateTime.now());
return Scaffold(
appBar: AppBar(title: const Text('Generate Token')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildConnectionStatus(),
const SizedBox(height: 16),
Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text('TOKEN NUMBER', style: TextStyle(fontSize: 18, color: Colors.grey)),
const SizedBox(height: 8),
if (_isLoading)
const CircularProgressIndicator()
else
Text(
_generatedToken ?? 'Error',
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold, letterSpacing: 2),
),
const SizedBox(height: 8),
Text(currentDate, style: const TextStyle(fontSize: 16)),
],
),
),
),
const SizedBox(height: 24),
TextFormField(
controller: _vehicleNoController,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(
labelText: 'Vehicle No',
prefixText: 'KL-',
border: OutlineInputBorder(),
),
validator: (v) => v!.isEmpty ? 'Enter vehicle number' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _contactNoController,
decoration: const InputDecoration(
labelText: 'Contact No',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
validator: (v) => v!.isEmpty ? 'Enter contact number' : null,
),
const Spacer(),
ElevatedButton.icon(
icon: const Icon(Icons.confirmation_number),
label: const Text('GENERATE TOKEN'),
onPressed: _isLoading ? null : _saveToken,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
),
),
),
);
}
Widget _buildConnectionStatus() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _isBtConnected ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _isBtConnected ? Colors.green : Colors.red),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(_isBtConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled, color: _isBtConnected ? Colors.green : Colors.red),
const SizedBox(width: 8),
Text(
_isBtConnected ? 'Display Connected' : 'Display Disconnected',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
if (_isConnecting)
const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
else if (!_isBtConnected)
TextButton(onPressed: _connectToDisplay, child: const Text('CONNECT')),
],
),
);
}
}
import 'dart:async';
class AnnouncementData {
final String tokenNumber;
final String vehicleNumber;
// --- CHANGE 1: Add the timestamp field ---
final DateTime timestamp;
AnnouncementData({
required this.tokenNumber,
required this.vehicleNumber,
// --- CHANGE 2: Add the timestamp to the constructor ---
required this.timestamp,
});
}
class AnnouncementService {
AnnouncementService._internal();
static final AnnouncementService _instance = AnnouncementService._internal();
factory AnnouncementService() {
return _instance;
}
final List<AnnouncementData> _history = [];
final _announcementController = StreamController<AnnouncementData>.broadcast();
Stream<AnnouncementData> get announcementStream => _announcementController.stream;
List<AnnouncementData> get history => List.unmodifiable(_history);
void startListening() {
// This method can be used in the future if you need to listen
// to a real-time source like Firestore streams directly.
}
void addAnnouncement({required String token, required String vehicle}) {
// --- CHANGE 3: When creating an announcement, include the current time ---
final announcement = AnnouncementData(
tokenNumber: token,
vehicleNumber: vehicle,
timestamp: DateTime.now(), // Record the current timestamp
);
_history.insert(0, announcement);
_announcementController.add(announcement);
}
void clearHistory() {
_history.clear();
}
void dispose() {
_announcementController.close();
}
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:intl/intl.dart';
import 'package:nms_billing/announcement_service.dart';
import 'package:nms_billing/firestore_service.dart';
import 'package:nms_billing/service_locator.dart';
enum TtsState { playing, stopped }
class AnnouncementScreen extends StatefulWidget {
const AnnouncementScreen({super.key});
@override
State<AnnouncementScreen> createState() => _AnnouncementScreenState();
}
class _AnnouncementScreenState extends State<AnnouncementScreen> {
late FlutterTts _flutterTts;
StreamSubscription? _announcementSubscription;
final AnnouncementService _announcementService = sl<AnnouncementService>();
// --- CHANGE 2: Instantiate FirestoreService ---
final FirestoreService _firestoreService = FirestoreService();
// State Management
final List<AnnouncementData> _recentAnnouncements = [];
TtsState _ttsState = TtsState.stopped;
String? _currentlyPlayingToken;
String? _ttsError;
String _currentLanguage = 'en-US';
@override
void initState() {
super.initState();
_flutterTts = FlutterTts();
_loadLanguageAndInitialize();
_listenForLiveUpdates();
// The midnight timer logic is no longer needed as Firestore queries handle this.
}
Future<void> _loadLanguageAndInitialize() async {
// The startup check is no longer needed here.
_currentLanguage = await _firestoreService.getLanguage(); // Use Firestore
await _initializeTts();
_loadInitialDataAndPlay();
}
Future<void> _initializeTts() async {
await _flutterTts.setSpeechRate(0.35);
_flutterTts.setStartHandler(() {
if (mounted) setState(() => _ttsState = TtsState.playing);
});
_flutterTts.setCompletionHandler(() {
if (mounted) {
setState(() {
_ttsState = TtsState.stopped;
_currentlyPlayingToken = null;
});
}
});
_flutterTts.setErrorHandler((msg) {
if (mounted) setState(() => _ttsError = msg);
});
await _flutterTts.setLanguage(_currentLanguage);
}
void _loadInitialDataAndPlay() {
// The logic here is simplified. The service's history
// is now the source of truth for live data.
if (mounted) {
setState(() {
_recentAnnouncements.clear();
_recentAnnouncements.addAll(_announcementService.history);
});
if (_recentAnnouncements.isNotEmpty) {
Future.delayed(const Duration(milliseconds: 400), () {
_speak(_recentAnnouncements.first);
});
}
}
}
void _listenForLiveUpdates() {
_announcementSubscription = _announcementService.announcementStream.listen((newAnnouncement) {
if (mounted) {
setState(() {
_recentAnnouncements.insert(0, newAnnouncement);
_ttsError = null;
});
_speak(newAnnouncement);
}
});
}
// --- CHANGE 4: Use FirestoreService to manage language preference ---
Future<void> _changeLanguage(String languageCode) async {
await _stop();
await _firestoreService.saveLanguage(languageCode); // Use Firestore
var result = await _flutterTts.setLanguage(languageCode);
if (result == 1) {
if (mounted) {
setState(() {
_currentLanguage = languageCode;
_ttsError = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(languageCode == 'ml-IN' ? 'ഭാഷ മലയാളം ആയി സജ്ജീകരിച്ചു' : 'Language set to English'),
duration: const Duration(seconds: 2),
),
);
}
} else {
if (mounted) setState(() => _ttsError = "Failed to set language.");
}
}
// The _speak method's logic remains the same as it doesn't interact with storage.
Future<void> _speak(AnnouncementData data) async {
await _stop();
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) setState(() => _currentlyPlayingToken = data.tokenNumber);
await _flutterTts.setLanguage(_currentLanguage);
String announcementText;
if (_currentLanguage == 'ml-IN') {
final tokenNumberSpoken = data.tokenNumber.replaceAll('NMS-', '');
String vehicleToSpeak;
final parts = data.vehicleNumber.split('-');
if (parts.length > 1) {
final vehiclePrefix = parts[0];
final vehicleSuffix = parts.sublist(1).join('').toUpperCase();
final RegExp segmentRegex = RegExp(r'([A-Z]+|\d+)');
final matches = segmentRegex.allMatches(vehicleSuffix);
final processedSegments = matches.map((match) {
String segment = match.group(0)!;
if (int.tryParse(segment) != null) {
final RegExp pairRegex = RegExp(r'.{1,2}');
final pairMatches = pairRegex.allMatches(segment);
return pairMatches.map((m) => m.group(0)!).join(' ');
} else {
return segment;
}
});
final suffixSpoken = processedSegments.join(' ');
vehicleToSpeak = '$vehiclePrefix $suffixSpoken';
} else {
vehicleToSpeak = data.vehicleNumber;
}
announcementText = "ടോക്കൺ നമ്പർ $tokenNumberSpoken. വാഹനത്തിന്റെ നമ്പർ $vehicleToSpeak.";
} else {
String lastFourDigits = data.tokenNumber.length >= 4
? data.tokenNumber.substring(data.tokenNumber.length - 4)
: data.tokenNumber;
String tokenToSpeak = lastFourDigits.split('').join(' ');
String vehicleToSpeak = data.vehicleNumber.replaceAll('-', ' ').toUpperCase().split('').join(' ');
announcementText = "Token number, $tokenToSpeak. Vehicle number, $vehicleToSpeak.";
}
var result = await _flutterTts.speak(announcementText);
if (result != 1) {
if (mounted) {
setState(() => _ttsError = "Speak command failed. Is language pack installed?");
}
}
}
Future<void> _stop() async {
var result = await _flutterTts.stop();
if (result == 1 && mounted) {
setState(() {
_ttsState = TtsState.stopped;
_currentlyPlayingToken = null;
});
}
}
@override
void dispose() {
_announcementSubscription?.cancel();
_flutterTts.stop();
super.dispose();
}
// --- NO CHANGES NEEDED IN THE BUILD METHOD ---
// The UI is driven by the state variables and is independent of the data source.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Live Announcements'),
actions: [
PopupMenuButton<String>(
onSelected: _changeLanguage,
icon: const Icon(Icons.language),
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
const PopupMenuItem<String>(value: 'en-US', child: Text('English')),
const PopupMenuItem<String>(value: 'ml-IN', child: Text('മലയാളം (Malayalam)')),
],
),
],
),
body: Column(
children: [
if (_ttsError != null)
Container(
width: double.infinity,
color: Colors.red[900],
padding: const EdgeInsets.all(12.0),
child: Text("TTS Error: $_ttsError", textAlign: TextAlign.center, style: const TextStyle(color: Colors.white)),
),
Expanded(
child: _recentAnnouncements.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mic_off_outlined, size: 60, color: Colors.grey),
SizedBox(height: 16),
Text('No announcements yet for today.', style: TextStyle(fontSize: 18, color: Colors.grey)),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: _recentAnnouncements.length,
itemBuilder: (context, index) {
final announcement = _recentAnnouncements[index];
return _buildAnnouncementCard(announcement);
},
),
),
],
),
);
}
Widget _buildAnnouncementCard(AnnouncementData announcement) {
final bool isPlaying = _ttsState == TtsState.playing && _currentlyPlayingToken == announcement.tokenNumber;
return Card(
elevation: 3,
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isPlaying ? Colors.green : Theme.of(context).primaryColor,
child: const Icon(Icons.campaign_outlined, color: Colors.white),
),
title: Text(announcement.tokenNumber, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
subtitle: Text('Vehicle: ${announcement.vehicleNumber} • ${DateFormat('hh:mm a').format(announcement.timestamp)}'),
trailing: IconButton(
iconSize: 30,
icon: Icon(isPlaying ? Icons.stop_circle_outlined : Icons.play_circle_outline),
color: isPlaying ? Colors.red : Theme.of(context).primaryColor,
onPressed: () => isPlaying ? _stop() : _speak(announcement),
),
),
);
}
}