diff --git a/ANDROID_VS_WEB_COMPARISON.md b/ANDROID_VS_WEB_COMPARISON.md new file mode 100644 index 0000000..87581ca --- /dev/null +++ b/ANDROID_VS_WEB_COMPARISON.md @@ -0,0 +1,414 @@ +# Android vs Web Platform Comparison + +> Quick reference for understanding differences between Android and Web versions +> Last Updated: July 30, 2026 + +--- + +## ๐Ÿ” Platform Feature Comparison + +| Feature | Android (Current) | Web (After Migration) | Implementation Effort | +|---------|-------------------|----------------------|----------------------| +| **Kiosk Mode** | โœ… Native Android kiosk mode | โš ๏ธ Browser fullscreen + kiosk flags | Low | +| **Auto-Start on Boot** | โœ… BootBroadcastReceiver | โš ๏ธ Browser autostart config | Low | +| **Wake Lock** | โœ… WakelockPlus plugin | โœ… Browser Wake Lock API | Low | +| **Screen Always On** | โœ… WindowManager flags | โœ… Wake Lock API | Low | +| **Background Service** | โœ… Foreground service | โŒ Not possible (tab must stay open) | N/A | +| **File Logging** | โœ… External storage | โœ… IndexedDB + download | Medium | +| **SignalR Real-time** | โœ… signalr_core | โœ… Same (WebSocket) | None | +| **Audio Playback** | โœ… just_audio | โš ๏ธ just_audio web or HTML5 Audio | Low | +| **Text-to-Speech** | โœ… flutter_tts | โš ๏ธ Web Speech API | Medium | +| **QR Scanner** | โœ… qr_code_scanner_plus | โš ๏ธ mobile_scanner (requires HTTPS) | Low | +| **Scheduled Tasks** | โœ… AlarmManager | โš ๏ธ JavaScript timers | Medium | +| **Offline Support** | โœ… Native app | โš ๏ธ PWA with Service Worker | Medium | +| **Push Notifications** | โœ… Firebase Cloud Messaging | โš ๏ธ Web Push API | Medium | +| **Network Detection** | โœ… connectivity_plus | โœ… Same (web support) | None | +| **Local Storage** | โœ… SharedPreferences | โœ… localStorage (same plugin) | None | +| **Multi-language** | โœ… Built-in | โœ… Built-in | None | +| **Updates** | โš ๏ธ Manual APK deployment | โœ… Instant (refresh page) | N/A | +| **Installation** | โš ๏ธ APK + ADB | โœ… Just open URL | N/A | +| **Security** | โœ… Full device control | โš ๏ธ Browser sandbox | N/A | +| **Crash Recovery** | โœ… Can restart automatically | โš ๏ธ Browser dependent | Low | + +**Legend:** +โœ… Fully Supported | โš ๏ธ Supported with Limitations | โŒ Not Supported + +--- + +## ๐Ÿ“ฆ Dependencies Comparison + +### Android-Only (Remove for Web) + +```yaml +# These will be removed +wakelock_plus: ^1.2.10 # Use Browser Wake Lock API instead +path_provider: ^2.1.5 # Use IndexedDB/localStorage instead +qr_code_scanner_plus: ^2.0.10+1 # Use mobile_scanner instead +``` + +### Web-Compatible (Keep) + +```yaml +# These work on both platforms +provider: ^6.1.2 # โœ… State management +get_it: ^8.0.2 # โœ… Dependency injection +signalr_core: ^1.1.1 # โœ… Real-time communication +connectivity_plus: ^6.1.0 # โœ… Network monitoring +shared_preferences: ^2.3.5 # โœ… Local storage +flutter_svg: ^2.0.14 # โœ… SVG rendering +intl: ^0.19.0 # โœ… Internationalization +logger: ^2.4.0 # โœ… Logging framework +http: ^1.2.2 # โœ… HTTP requests +``` + +### Web-Specific (Add) + +```yaml +# Add these for web support +mobile_scanner: ^3.5.2 # QR scanner with web support +indexed_db: ^0.3.0 # Structured data storage +universal_html: ^2.2.4 # Access to web APIs +js: ^0.6.7 # JavaScript interop +``` + +### Requires Testing/Replacement + +```yaml +just_audio: ^0.9.42 # โš ๏ธ Has web support, test needed +flutter_tts: ^4.2.0 # โš ๏ธ Limited web support, use Web Speech API +``` + +--- + +## ๐Ÿ”ง Code Changes Summary + +### Files That Need Changes + +| File | Type of Change | Complexity | +|------|---------------|------------| +| `lib/main.dart` | Modify | Low - Add platform detection | +| `lib/services/logger_service.dart` | New File | Medium - Create web version | +| `lib/services/wakelock_service.dart` | New File | Low - Wrapper for web/mobile | +| `lib/services/text_to_speech_service.dart` | Modify | Medium - Add web implementation | +| `lib/services/audio_service.dart` | Modify | Low - Test or add web fallback | +| `lib/utilities/native_method_handler.dart` | Modify | Low - Add platform checks | +| `lib/view_models/screen_config_view_model.dart` | Modify | Medium - Add web lifecycle | +| `lib/services/connectivity_service.dart` | Modify | Low - Add IP detection for web | +| `lib/config/dependency_injection.dart` | Modify | Medium - Platform-aware DI | +| `web/index.html` | Create | Low - Standard web config | +| `web/manifest.json` | Create | Low - PWA manifest | + +### Files That Don't Need Changes + +| File Category | Files | Reason | +|---------------|-------|--------| +| **Models** | All `lib/models/*.dart` | Platform-agnostic Dart code | +| **Repositories** | `lib/repositories/*.dart` | Use standard HTTP/SignalR | +| **Constants** | `lib/constants/*.dart` | Pure Dart constants | +| **UI Widgets** | Most of `lib/views/*.dart` | Flutter renders same | +| **View Models** | Business logic in ViewModels | Dart code, no platform API | + +### Files to Delete (Not Needed for Web) + +| File | Reason | +|------|--------| +| `android/` folder | Android-specific native code | +| `ios/` folder | iOS-specific native code | +| All `.kt` files | Kotlin is Android-only | +| `qline_scripts/*.sh` | Deployment scripts for Android APK | + +--- + +## ๐Ÿš€ Deployment Comparison + +### Android Deployment (Current) + +```bash +# Build +flutter build apk --release + +# Deploy to devices +cd qline_scripts +sh deploy_qline.sh -ipfile device_ips_all.txt -version 9.4 + +# Update (requires ADB connection) +adb install -r app-release.apk +``` + +**Pros:** +- Full control over device +- Works offline always +- No browser dependencies + +**Cons:** +- Manual deployment process +- Requires physical access or ADB +- Slower update rollout +- Version management complex + +### Web Deployment (After Migration) + +```bash +# Build +flutter build web --release + +# Deploy to Firebase (example) +firebase deploy + +# Or deploy to own server +rsync -avz build/web/ user@server:/var/www/qline/ +``` + +**Pros:** +- โœ… Instant updates (just refresh) +- โœ… Deploy once, available everywhere +- โœ… No APK management +- โœ… Central control +- โœ… Easy rollback + +**Cons:** +- โš ๏ธ Requires internet for updates +- โš ๏ธ Browser must be configured +- โš ๏ธ HTTPS required for camera + +--- + +## ๐ŸŽฏ Feature Parity Matrix + +### Core Features + +| Feature | Android | Web | Notes | +|---------|---------|-----|-------| +| Patient Queue Display | โœ… | โœ… | Identical | +| Real-time SignalR Updates | โœ… | โœ… | Identical | +| Audio Call Tones | โœ… | โš ๏ธ | Web: autoplay restrictions | +| TTS Announcements | โœ… | โš ๏ธ | Web: Web Speech API | +| Multi-language (EN/AR) | โœ… | โœ… | Identical | +| RTL Support | โœ… | โœ… | Identical | +| Weather Widget | โœ… | โœ… | Identical | +| Prayer Times Widget | โœ… | โœ… | Identical | +| RSS Feed Widget | โœ… | โœ… | Identical | +| Configuration via IP | โœ… | โš ๏ธ | Web: URL param or manual | +| Health Check Logging | โœ… | โš ๏ธ | Web: IndexedDB + download | +| Crash Logging | โœ… | โš ๏ธ | Web: Console + IndexedDB | + +### Kiosk Features + +| Feature | Android | Web | Notes | +|---------|---------|-----|-------| +| QR Code Scanner | โœ… | โš ๏ธ | Web: Requires HTTPS | +| Manual ID Entry | โœ… | โœ… | Identical | +| Ticket Generation | โœ… | โœ… | Identical | +| Language Selection | โœ… | โœ… | Identical | +| Queue Selection | โœ… | โœ… | Identical | +| Thank You Screen | โœ… | โœ… | Identical | + +### System Features + +| Feature | Android | Web | Notes | +|---------|---------|-----|-------| +| 24/7 Operation | โœ… | โš ๏ธ | Web: Browser must stay open | +| Auto-restart | โœ… | โš ๏ธ | Web: Can reload page | +| Boot on Startup | โœ… | โš ๏ธ | Web: Configure browser autostart | +| Crash Recovery | โœ… | โš ๏ธ | Web: Browser dependent | +| Memory Management | โœ… | โš ๏ธ | Web: Browser handles | +| Log Rotation | โœ… | โš ๏ธ | Web: Manual download needed | + +--- + +## ๐Ÿ’ฐ Cost Comparison + +### Android Deployment + +**Initial Costs:** +- Android TV boxes/displays: $100-300 per device +- One-time setup per device +- ADB deployment setup + +**Ongoing Costs:** +- Manual deployment time +- Physical access for updates +- Maintenance visits + +**Advantages:** +- One-time cost +- No hosting fees +- Full offline operation + +### Web Deployment + +**Initial Costs:** +- Thin clients/mini PCs: $150-400 per device (optional - can reuse existing) +- SSL certificate: Free (Let's Encrypt) +- Domain name: $10-15/year + +**Hosting Options:** +- Firebase Hosting: Free (up to 10GB) +- Self-hosted: Server costs +- Cloud (AWS/Azure): $20-100/month + +**Ongoing Costs:** +- Hosting: $0-100/month +- Minimal deployment time +- Remote updates (no visits) + +**Advantages:** +- Instant updates +- Remote management +- Central monitoring + +--- + +## โšก Performance Comparison + +### Android + +| Metric | Performance | +|--------|-------------| +| App Startup | Fast (2-3 seconds) | +| Memory Usage | 80-150 MB | +| CPU Usage | Low (5-10%) | +| Network | SignalR WebSocket | +| Rendering | Native Android | +| Wake Lock | Native, reliable | +| Audio Latency | Low | +| Camera Access | Direct, fast | + +### Web (Expected) + +| Metric | Performance | +|--------|-------------| +| Page Load | Fast (2-4 seconds first load) | +| Memory Usage | 100-200 MB (browser + app) | +| CPU Usage | Low-Medium (10-20% with browser) | +| Network | SignalR WebSocket (same) | +| Rendering | Chromium rendering engine | +| Wake Lock | Browser API, reliable on Chrome | +| Audio Latency | Low-Medium (browser dependent) | +| Camera Access | WebRTC, slight delay | + +**Overall:** Web performance is comparable, with slightly higher memory usage due to browser overhead. + +--- + +## ๐Ÿ”’ Security Comparison + +### Android + +**Pros:** +- Full control over device +- Can lock down Android completely +- No browser vulnerabilities +- Native app sandbox + +**Cons:** +- Physical device access required for setup +- APK can be extracted/reverse engineered +- Must manage device security + +### Web + +**Pros:** +- HTTPS encryption +- Browser security features +- Easy SSL certificate management +- Remote security updates + +**Cons:** +- Browser can be accessed +- JavaScript can be viewed +- Depends on browser security +- Users can close browser (without kiosk mode) + +**Recommendation:** +- Both can be secured adequately +- Web requires HTTPS + browser kiosk mode +- Android requires device lockdown + +--- + +## ๐Ÿ“ˆ Migration Timeline + +| Phase | Android (Maintain) | Web (Build) | Total Time | +|-------|-------------------|-------------|------------| +| **Phase 1: Setup** | 0 days | 1-2 days | 1-2 days | +| **Phase 2: Services** | 0 days | 3-5 days | 3-5 days | +| **Phase 3: Core Updates** | 0 days | 2-3 days | 2-3 days | +| **Phase 4: UI Updates** | 0 days | 2-3 days | 2-3 days | +| **Phase 5: Testing** | 1 day | 4-6 days | 5-7 days | +| **Phase 6: Deployment** | 0 days | 3-5 days | 3-5 days | +| **Phase 7: Kiosk Setup** | 0 days | 2-3 days | 2-3 days | +| **Phase 8: Documentation** | 0 days | 1-2 days | 1-2 days | +| **TOTAL** | 1 day | 22-31 days | **23-32 days** | + +--- + +## ๐ŸŽฏ Decision Matrix + +### Choose Android If: + +- โœ… You need complete device control +- โœ… You have IT staff for physical deployment +- โœ… You want true offline operation +- โœ… You need guaranteed 24/7 uptime +- โœ… You don't need frequent updates + +### Choose Web If: + +- โœ… You want instant updates across all locations +- โœ… You want centralized management +- โœ… You have stable HTTPS hosting +- โœ… You want easier deployment +- โœ… You can use browser kiosk mode +- โœ… You want cross-platform support (future: tablets, desktops) + +### Choose Both If: + +- โœ… You want maximum flexibility +- โœ… You can maintain both codebases (with shared business logic) +- โœ… You want to test web in pilot locations first +- โœ… You need backup deployment option + +--- + +## ๐Ÿ“Š Recommendation + +### For HMG_QLine Project: + +**Recommended Approach:** **Gradual Migration** + +1. **Phase 1 (Now):** Keep Android as primary +2. **Phase 2 (2-4 weeks):** Build web version +3. **Phase 3 (1 week):** Pilot test web in 2-3 locations +4. **Phase 4 (2 weeks):** Evaluate feedback +5. **Phase 5 (Ongoing):** Migrate locations gradually + +**Reasoning:** +- โœ… No disruption to current operations +- โœ… Test web thoroughly before full rollout +- โœ… Keep Android as fallback +- โœ… Evaluate real-world performance +- โœ… Easy to roll back if issues arise + +--- + +## ๐Ÿ“ž Quick Reference + +**Need detailed implementation?** +โ†’ See [WEB_MIGRATION_GUIDE.md](WEB_MIGRATION_GUIDE.md) + +**Need step-by-step checklist?** +โ†’ See [WEB_MIGRATION_CHECKLIST.md](WEB_MIGRATION_CHECKLIST.md) + +**Need project architecture?** +โ†’ See [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) + +**Need recent changes info?** +โ†’ See [QUICK_SUMMARY.md](QUICK_SUMMARY.md) + +--- + +**Last Updated:** July 30, 2026 +**Status:** Migration not started (planning phase) + + diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 39b23ce..9071277 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -1,4 +1,4 @@ -# HMG_QLine - Project Context Documentation +O# HMG_QLine - Project Context Documentation > **Last Updated:** July 30, 2026 > **Project Type:** Flutter Healthcare Queue Management System @@ -1739,6 +1739,60 @@ adb shell cmd appops set com.example.hmg_qline.hmg_qline RUN_IN_BACKGROUND allow --- +nl## ๐ŸŒ Web Platform Compatibility + +### Current Status +**Platform:** Android Only (AC-Powered Kiosk Displays) +**Web Support:** Not Implemented (Requires Migration) + +### Migration Feasibility +โœ… **VIABLE** - Core functionality can be migrated to web with platform abstraction + +**For complete web migration details, see:** [WEB_MIGRATION_GUIDE.md](WEB_MIGRATION_GUIDE.md) + +### Quick Migration Overview + +#### What Works As-Is on Web +- โœ… Core business logic (MVVM, ViewModels) +- โœ… SignalR real-time communication +- โœ… Provider state management +- โœ… API calls and HTTP requests +- โœ… UI components and widgets +- โœ… Multi-language support (English/Arabic) + +#### What Needs Replacement +- โŒ All Kotlin native code (MainActivity, AlarmScheduler, etc.) +- โŒ Platform channels and native methods +- โŒ File-based logging โ†’ Replace with IndexedDB +- โŒ Wakelock Plus โ†’ Replace with Browser Wake Lock API +- โŒ Flutter TTS โ†’ Replace with Web Speech API +- โŒ QR Scanner โ†’ Replace with mobile_scanner (web support) +- โŒ Android lifecycle โ†’ Replace with Page Visibility API + +#### Key Limitations on Web +- No true kiosk mode (browser can be closed) +- No auto-start on boot (browser must be configured) +- No background service (tab must stay open) +- Camera requires HTTPS +- Audio autoplay restrictions +- Limited file system access + +#### Estimated Migration Effort +- **Development:** 15-20 days +- **Testing:** 5-7 days +- **Deployment:** 3-5 days +- **Total:** 23-32 days + +#### Recommended Deployment +- Chrome/Chromium in kiosk mode +- HTTPS with SSL certificate +- Progressive Web App (PWA) with Service Worker +- Dedicated mini PC or thin client hardware + +**For step-by-step migration instructions, code examples, and deployment guides, see:** [WEB_MIGRATION_GUIDE.md](WEB_MIGRATION_GUIDE.md) + +--- + ## ๐Ÿ“– Glossary - **AC-Powered Kiosk**: Android device permanently plugged into power, not battery-powered @@ -1746,12 +1800,16 @@ adb shell cmd appops set com.example.hmg_qline.hmg_qline RUN_IN_BACKGROUND allow - **Doze Mode**: Android 6+ battery optimization that restricts background processing - **Health Check**: Periodic verification that app is functioning correctly - **Hub**: SignalR WebSocket server for real-time communication +- **IndexedDB**: Browser database for storing structured data (web alternative to file storage) - **Kiosk Mode**: Display mode where app runs continuously without user intervention +- **PWA**: Progressive Web App - web app that can work offline and be installed - **QType**: Queue type (appointment, lab, rad, general) - **SignalR**: Real-time communication library using WebSockets - **TTS**: Text-to-Speech for voice announcements - **Uptime**: Duration the app has been running since last start +- **Wake Lock API**: Browser API to prevent device from sleeping (web equivalent of wakelock) - **Wakelock**: Android API to prevent device from sleeping +- **Web Speech API**: Browser API for speech synthesis (web equivalent of TTS) --- diff --git a/README.md b/README.md index c11f7a2..816933c 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,22 @@ background service integration to ensure reliability and uptime. **For complete project context, architecture, and implementation details, see:** - **[PROJECT_CONTEXT.md](PROJECT_CONTEXT.md)** - Comprehensive documentation for developers and AI models +- **[WEB_MIGRATION_GUIDE.md](WEB_MIGRATION_GUIDE.md)** - Complete guide for migrating to web platform +- **[WEB_MIGRATION_CHECKLIST.md](WEB_MIGRATION_CHECKLIST.md)** - Step-by-step migration checklist +- **[ANDROID_VS_WEB_COMPARISON.md](ANDROID_VS_WEB_COMPARISON.md)** - Platform comparison and decision guide - **[QUICK_SUMMARY.md](QUICK_SUMMARY.md)** - Quick reference for recent changes - **[CHANGES_EXPLANATION.md](CHANGES_EXPLANATION.md)** - Detailed changelog - **[AC_POWERED_KIOSK_SUMMARY.md](AC_POWERED_KIOSK_SUMMARY.md)** - Kiosk-specific documentation +### ๐ŸŒ Web Deployment + +- **[WEB_DEPLOYMENT_NOTES.md](WEB_DEPLOYMENT_NOTES.md)** - โš ๏ธ **START HERE for web deployment** +- **[CORS_ISSUE_SOLUTION.md](CORS_ISSUE_SOLUTION.md)** - Complete CORS configuration guide (server-side) +- **[CONFIGURATION_API_STATUS.md](CONFIGURATION_API_STATUS.md)** - Configuration API details and status +- **[HUB_STATUS_BUG_FIX.md](HUB_STATUS_BUG_FIX.md)** - Fix for incorrect hub status display +- **[IP_PERSISTENCE_FIX.md](IP_PERSISTENCE_FIX.md)** - IP persistence and localStorage handling +- **[FINAL_STATUS_SUMMARY.md](FINAL_STATUS_SUMMARY.md)** - Complete status of all web issues + ## Features - Patient queue management across all screens diff --git a/lib/main.dart b/lib/main.dart index 08be82e..7ff8d43 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:hmg_qline/config/dependency_injection.dart'; import 'package:hmg_qline/config/routes.dart'; import 'package:hmg_qline/constants/app_constants.dart'; @@ -12,6 +13,7 @@ import 'package:hmg_qline/view_models/screen_config_view_model.dart'; import 'package:hmg_qline/views/view_helpers/size_config.dart'; import 'package:provider/provider.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:universal_html/html.dart' as html; void main() { runZonedGuarded(() async { @@ -20,7 +22,13 @@ void main() { await AppDependencies.addDependencies(); // initialize crash handler after DI setup getIt.get().setupGlobalErrorHandlers(); - await WakelockPlus.enable(); + + // Platform-specific wake lock + if (kIsWeb) { + await _initializeWeb(); + } else { + await WakelockPlus.enable(); + } } catch (e) { log('Initialization error: $e'); } @@ -35,6 +43,21 @@ void main() { }); } +Future _initializeWeb() async { + try { + // Use wakelock_plus for web (it handles the Web Wake Lock API internally) + await WakelockPlus.enable(); + log('Web wake lock activated'); + } catch (e) { + log('Wake lock not supported: $e'); + } + + // Prevent browser context menu + html.document.onContextMenu.listen((event) => event.preventDefault()); + + log('Web platform initialized - Press F11 for fullscreen mode'); +} + class MyApp extends StatelessWidget { const MyApp({super.key}); diff --git a/lib/repositories/screen_details_repo.dart b/lib/repositories/screen_details_repo.dart index 0a3db26..ebbfd45 100644 --- a/lib/repositories/screen_details_repo.dart +++ b/lib/repositories/screen_details_repo.dart @@ -39,7 +39,7 @@ class ScreenDetailsRepoImp implements ScreenDetailsRepo { @override Future getGlobalScreenConfigurations({required String ipAddress}) async { - // try { + try { var params = { "ipAddress": ipAddress.toString(), "apiKey": AppConstants.apiKey.toString(), @@ -59,14 +59,21 @@ class ScreenDetailsRepoImp implements ScreenDetailsRepo { return globalConfigurationsModel.first; } return null; - // } catch (e) { - // log("record:"); - // log(e.toString()); - // loggerService.logError(e.toString()); - // loggerService.logToFile(message: e.toString(), source: "getGlobalScreenConfigurations-> screen_details_repo.dart", type: LogTypeEnum.error); - // InfoComponents.showToast(e.toString()); - // return null; - // } + } catch (e) { + log("getGlobalScreenConfigurations error:"); + log(e.toString()); + loggerService.logError(e.toString()); + loggerService.logToFile(message: e.toString(), source: "getGlobalScreenConfigurations-> screen_details_repo.dart", type: LogTypeEnum.error); + + // Check if this is a CORS error on web + if (e.toString().contains('Failed to fetch') || e.toString().contains('CORS')) { + loggerService.logError('โš ๏ธ CORS ERROR: Configuration API is also blocked by browser CORS policy.'); + loggerService.logError('๐Ÿ“‹ The backend needs CORS headers for ALL API endpoints, not just SignalR hub.'); + } + + InfoComponents.showToast(e.toString()); + return null; + } } @override diff --git a/lib/repositories/signalR_repo.dart b/lib/repositories/signalR_repo.dart index 5e00c6a..fa75ee0 100644 --- a/lib/repositories/signalR_repo.dart +++ b/lib/repositories/signalR_repo.dart @@ -1,10 +1,12 @@ import 'dart:developer'; import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:hmg_qline/services/logger_service.dart'; import 'package:hmg_qline/utilities/enums.dart'; import 'package:http/io_client.dart'; import 'package:hmg_qline/constants/app_constants.dart'; import 'package:signalr_core/signalr_core.dart'; +import 'package:universal_html/html.dart' as html; abstract class SignalrRepo { Future startHubConnection({ @@ -22,6 +24,7 @@ abstract class SignalrRepo { class SignalrRepoImp implements SignalrRepo { HubConnection? connection; LoggerService loggerService; + String? _lastDeviceIp; // Store last used IP for reconnection bool get isConnected => connection?.state == HubConnectionState.connected; @@ -37,19 +40,34 @@ class SignalrRepoImp implements SignalrRepo { required Function(dynamic) onHubDisconnected, }) async { try { + // On web, retrieve from localStorage if deviceIp is empty + String actualDeviceIp = deviceIp; + if (kIsWeb && (deviceIp.isEmpty || deviceIp == '')) { + final storedIp = html.window.localStorage['device_ip']; + if (storedIp != null && storedIp.isNotEmpty && storedIp != 'null') { + actualDeviceIp = storedIp; + log('๐Ÿ“ก Retrieved IP from localStorage for SignalR: $actualDeviceIp'); + } + } + + // Store the IP for future reconnection attempts + _lastDeviceIp = actualDeviceIp; + String hubBaseURL = ApiConstants.baseUrlHub; if ((connection != null) && (connection!.state == HubConnectionState.connected || connection!.state == HubConnectionState.connecting)) { return true; } - connection = HubConnectionBuilder() - .withUrl( - "$hubBaseURL?IPAddress=$deviceIp", - HttpConnectionOptions( - client: IOClient(HttpClient()..badCertificateCallback = (x, y, z) => true), - logging: (level, message) => log(message), - )) - .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // Custom retry delays - .build(); + + // Build connection with platform-specific options + final connectionBuilder = HubConnectionBuilder().withUrl( + "$hubBaseURL?IPAddress=$actualDeviceIp", + HttpConnectionOptions( + client: kIsWeb ? null : IOClient(HttpClient()..badCertificateCallback = (x, y, z) => true), + logging: (level, message) => log(message), + ), + ).withAutomaticReconnect([0, 2000, 5000, 10000, 30000]); + + connection = connectionBuilder.build(); connection!.serverTimeoutInMilliseconds = 120000; // 2 minutes connection!.keepAliveIntervalInMilliseconds = 15000; // 15 seconds keep-alive @@ -64,6 +82,46 @@ class SignalrRepoImp implements SignalrRepo { loggerService.logToFile(message: "SignalR reconnect attempt #$reconnectAttempts", source: "startHubConnection -> signalR_repo.dart", type: LogTypeEnum.data); await Future.delayed(reconnectDelay); try { + // On web, check localStorage again for IP before reconnecting + String reconnectIp = _lastDeviceIp ?? actualDeviceIp; + if (kIsWeb) { + final storedIp = html.window.localStorage['device_ip']; + if (storedIp != null && storedIp.isNotEmpty && storedIp != 'null') { + reconnectIp = storedIp; + } + } + + // If connection is completely dead, rebuild it + if (connection == null || connection!.state == HubConnectionState.disconnected) { + log('๐Ÿ”„ Rebuilding SignalR connection with IP: $reconnectIp'); + + // Build connection with platform-specific options + final connectionBuilder = HubConnectionBuilder().withUrl( + "$hubBaseURL?IPAddress=$reconnectIp", + HttpConnectionOptions( + client: kIsWeb ? null : IOClient(HttpClient()..badCertificateCallback = (x, y, z) => true), + logging: (level, message) => log(message), + ), + ).withAutomaticReconnect([0, 2000, 5000, 10000, 30000]); + + connection = connectionBuilder.build(); + connection!.serverTimeoutInMilliseconds = 120000; + connection!.keepAliveIntervalInMilliseconds = 15000; + + // Re-register event handlers + connection!.on(ApiConstants.sendQLinePatientCall, (List? message) { + onHubTicketCall(message); + }); + connection!.on(ApiConstants.sendQLineConfig, (List? message) { + onHubConfigCall(message); + }); + connection!.onreconnecting((exception) => onHubConnecting(exception)); + connection!.onreconnected((connectionId) { + log(connectionId.toString()); + onHubReconnected(connectionId); + }); + } + await connection!.start(); loggerService.logToFile(message: "SignalR reconnected after disconnect", source: "startHubConnection -> signalR_repo.dart", type: LogTypeEnum.data); reconnectAttempts = 0; // Reset on success @@ -94,6 +152,20 @@ class SignalrRepoImp implements SignalrRepo { } catch (e) { loggerService.logError(e.toString()); loggerService.logToFile(message: e.toString(), source: "startHubConnection -> signalR_repo.dart", type: LogTypeEnum.error); + + // Check if this is a CORS error (web-specific) + if (kIsWeb && e.toString().contains('Failed to fetch')) { + loggerService.logError('โš ๏ธ CORS ERROR: The web browser is blocking the connection to the SignalR hub.'); + loggerService.logError('๐Ÿ“‹ This is a server configuration issue. The backend needs to add CORS headers.'); + loggerService.logError('โœ… Mobile app works fine - this only affects web browsers.'); + loggerService.logError('๐Ÿ“– See CORS_ISSUE_SOLUTION.md for detailed fix instructions.'); + loggerService.logToFile( + message: 'CORS Error on Web - Server needs CORS configuration. See CORS_ISSUE_SOLUTION.md', + source: "startHubConnection -> signalR_repo.dart", + type: LogTypeEnum.error, + ); + } + return false; } } @@ -108,7 +180,7 @@ class SignalrRepoImp implements SignalrRepo { @override closeConnection() async { - if (connection!.state == HubConnectionState.connected || connection!.state == HubConnectionState.connecting) { + if (connection != null && (connection!.state == HubConnectionState.connected || connection!.state == HubConnectionState.connecting)) { await connection!.stop(); } } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 2a441a3..ab9bf90 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:just_audio/just_audio.dart'; +import 'package:universal_html/html.dart' as html; abstract class AudioService { Future playTone({required String path, bool isMute = false}); @@ -24,6 +26,8 @@ class AudioServiceImp implements AudioService { @override Future playTone({required String path, bool isMute = false}) async { + // just_audio has web support - should work on both platforms + // Web note: First audio play may require user interaction due to browser autoplay policy audioPlayerInstance.setAsset(path); if (isMute) { audioPlayerInstance.setVolume(0.0); diff --git a/lib/services/connectivity_service.dart b/lib/services/connectivity_service.dart index 275aadc..ff1bcae 100644 --- a/lib/services/connectivity_service.dart +++ b/lib/services/connectivity_service.dart @@ -2,6 +2,19 @@ import 'dart:async'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:universal_html/html.dart' as html; +import 'dart:developer' as dev; +import 'package:js/js.dart'; + +@JS('startIPDetection') +external void startIPDetectionJS(); + +@JS('deviceIPResult') +external String? get deviceIPResultJS; + +@JS('deviceIPDetecting') +external bool? get deviceIPDetectingJS; abstract class ConnectivityService { void subscribeToConnectivityChange({ @@ -42,22 +55,128 @@ class ConnectivityServiceImp implements ConnectivityService { @override Future getCurrentScreenIP() async { + // Web: Auto-detect IP or get from URL/localStorage + if (kIsWeb) { + dev.log('๐ŸŒ Web platform - attempting to get device IP...'); + + // Priority 1: Check URL query parameter: ?ip=12.4.5.1 + final uri = Uri.base; + if (uri.queryParameters.containsKey('ip')) { + final ip = uri.queryParameters['ip']!; + dev.log('โœ… IP from URL parameter: $ip'); + try { + html.window.localStorage['device_ip'] = ip; + dev.log('๐Ÿ’พ Saved IP to localStorage: $ip'); + } catch (e) { + dev.log('โŒ Failed to save IP to localStorage: $e'); + } + return ip; + } + + // Priority 2: Check localStorage FIRST (before auto-detect to avoid delay) + String? storedIp; + try { + storedIp = html.window.localStorage['device_ip']; + dev.log('๐Ÿ” Checking localStorage for saved IP...'); + dev.log(' localStorage[device_ip] = $storedIp'); + dev.log(' storedIp type: ${storedIp.runtimeType}'); + dev.log(' storedIp == null: ${storedIp == null}'); + dev.log(' storedIp.isEmpty: ${storedIp?.isEmpty}'); + dev.log(' storedIp == "null": ${storedIp == 'null'}'); + } catch (e) { + dev.log('โŒ Error accessing localStorage: $e'); + storedIp = null; + } + + if (storedIp != null && storedIp.isNotEmpty && storedIp != 'null') { + dev.log('โœ… IP from localStorage: $storedIp'); + return storedIp; + } else { + dev.log('โš ๏ธ localStorage check failed - storedIp: "$storedIp"'); + } + + // Priority 3: Try to auto-detect IP using WebRTC (only if not in localStorage) + dev.log('๐Ÿ“ก No stored IP found, attempting auto-detection...'); + try { + final detectedIP = await _autoDetectIP(); + if (detectedIP != null && detectedIP.isNotEmpty) { + dev.log('โœ… IP auto-detected from device: $detectedIP'); + try { + html.window.localStorage['device_ip'] = detectedIP; + dev.log('๐Ÿ’พ Saved auto-detected IP to localStorage'); + } catch (e) { + dev.log('โŒ Failed to save auto-detected IP to localStorage: $e'); + } + return detectedIP; + } + } catch (e) { + dev.log('โš ๏ธ Auto-detection failed: $e'); + } + + // No IP found + dev.log('โš ๏ธ No IP address detected'); + dev.log('๐Ÿ“Œ Add to URL: ${uri.origin}/?ip=YOUR_DEVICE_IP'); + return ''; + } + + // Android Native: Get actual IP from network interface + dev.log('๐Ÿ“ฑ Android native - getting IP from network interface...'); const eth = "eth0"; const wlan = "wlan0"; String currentDeviceIp = ""; - final ips = await NetworkInterface.list(type: InternetAddressType.IPv4); - for (var interface in ips) { - if (interface.name == eth) { - for (var address in interface.addresses) { - currentDeviceIp = address.address; + + try { + final ips = await NetworkInterface.list(type: InternetAddressType.IPv4); + for (var interface in ips) { + if (interface.name == eth) { + for (var address in interface.addresses) { + currentDeviceIp = address.address; + dev.log('โœ… IP from ethernet (eth0): $currentDeviceIp'); + } } - } - if (interface.name == wlan) { - for (var address in interface.addresses) { - currentDeviceIp = address.address; + if (interface.name == wlan) { + for (var address in interface.addresses) { + currentDeviceIp = address.address; + dev.log('โœ… IP from WiFi (wlan0): $currentDeviceIp'); + } } } + } catch (e) { + dev.log('โŒ Failed to get network interface IP: $e'); } + return currentDeviceIp; } + + /// Auto-detect device IP using WebRTC (works on web browsers) + Future _autoDetectIP() async { + try { + // Start IP detection in JavaScript + startIPDetectionJS(); + + // Poll for result + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsed.inSeconds < 5) { + await Future.delayed(const Duration(milliseconds: 100)); + + // Check if still detecting + final isDetecting = deviceIPDetectingJS; + if (isDetecting == false || isDetecting == null) { + // Detection completed + final result = deviceIPResultJS; + + if (result != null && result != 'TIMEOUT' && result != 'ERROR') { + return result; + } + break; + } + } + + dev.log('โฑ๏ธ IP detection timeout or failed'); + return null; + } catch (e) { + dev.log('โŒ Auto-detection error: $e'); + return null; + } + } } diff --git a/lib/services/logger_service.dart b/lib/services/logger_service.dart index 6c0732a..a7fe440 100644 --- a/lib/services/logger_service.dart +++ b/lib/services/logger_service.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:hmg_qline/config/dependency_injection.dart'; import 'package:hmg_qline/constants/app_constants.dart'; import 'package:hmg_qline/services/cache_service.dart'; @@ -59,10 +60,19 @@ class LoggerServiceImp implements LoggerService { @override Future logToFile({required String message, required LogTypeEnum type, required String source}) async { try { - ScreenConfigViewModel screenConfigViewModel = getIt.get(); final timestamp = DateFormat('yyyy-MM-dd hh:mm:ss a').format(DateTime.now()); final formattedMessage = "[$timestamp] [SOURCE: $source] ${type.name.toUpperCase()}: $message"; + // Web: Console logging only + if (kIsWeb) { + // Always log to console on web + print(formattedMessage); + // TODO: Optional - Store in localStorage/IndexedDB for later download + return; + } + + // Android: File logging (existing implementation) + ScreenConfigViewModel screenConfigViewModel = getIt.get(); final baseDir = await getExternalStorageDirectory(); final logDir = Directory('${baseDir!.path}/logs'); if (!await logDir.exists()) { @@ -93,7 +103,6 @@ class LoggerServiceImp implements LoggerService { @override Future logCrash({required Object error, StackTrace? stackTrace, String? context, String source = "CRASH"}) async { try { - ScreenConfigViewModel screenConfigViewModel = getIt.get(); final timestamp = DateFormat('yyyy-MM-dd hh:mm:ss a').format(DateTime.now()); const sep = '==================== CRASH ===================='; final contextStr = context ?? 'No context provided'; @@ -102,6 +111,15 @@ class LoggerServiceImp implements LoggerService { final crashMessage = '$sep\n[$timestamp]\nCONTEXT: $contextStr\nERROR: $errorMsg\nSTACKTRACE:\n$stack\n$sep\n'; + // Web: Console logging only + if (kIsWeb) { + print(crashMessage); + logger.e('Crash logged: $errorMsg'); + return; + } + + // Android: File logging (existing implementation) + ScreenConfigViewModel screenConfigViewModel = getIt.get(); final baseDir = await getExternalStorageDirectory(); final logDir = Directory('${baseDir!.path}/logs'); if (!await logDir.exists()) { diff --git a/lib/services/text_to_speech_service.dart b/lib/services/text_to_speech_service.dart index 497e309..560095e 100644 --- a/lib/services/text_to_speech_service.dart +++ b/lib/services/text_to_speech_service.dart @@ -1,11 +1,13 @@ import 'dart:developer'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_tts/flutter_tts.dart'; import 'package:hmg_qline/constants/app_constants.dart'; import 'package:hmg_qline/models/global_config_model.dart'; import 'package:hmg_qline/models/ticket_model.dart'; import 'package:hmg_qline/services/logger_service.dart'; import 'package:hmg_qline/utilities/enums.dart'; +import 'package:universal_html/html.dart' as html; import 'package:hmg_qline/utilities/extensions.dart'; abstract class TextToSpeechService { @@ -16,6 +18,9 @@ abstract class TextToSpeechService { }); void listenToTextToSpeechEvents({required Function() onVoiceCompleted}); + + // Test function to speak any text + Future testSpeak(String text, {LanguageEnum language = LanguageEnum.english}); } class TextToSpeechServiceImp implements TextToSpeechService { @@ -34,6 +39,13 @@ class TextToSpeechServiceImp implements TextToSpeechService { required GlobalConfigurationsModel globalConfigurationsModel, bool isMute = false, }) async { + // Web Speech API implementation + if (kIsWeb) { + await _speechTextWeb(ticket: ticket, globalConfigurationsModel: globalConfigurationsModel, isMute: isMute); + return; + } + + // Android implementation (existing code) log("ticket data i got: ${ticket.ticketModel.toString()}"); const ttsGoogleEngine = 'com.google.android.tts'; LanguageEnum langEnum = ticket.ticketModel!.voiceLanguageEnum; @@ -188,4 +200,107 @@ class TextToSpeechServiceImp implements TextToSpeechService { loggerService.logInfo("setCancelHandler"); }); } + + // Web Speech API implementation + Future _speechTextWeb({ + required TicketDetailsModel ticket, + required GlobalConfigurationsModel globalConfigurationsModel, + bool isMute = false, + }) async { + if (isMute) return; + + try { + final synthesis = html.window.speechSynthesis; + if (synthesis == null) { + log('Web Speech API not supported'); + return; + } + + // Construct message + String preVoice = ticket.ticketModel!.ticketNoText; + String postVoice = ''; + if (globalConfigurationsModel.qTypeEnum == QTypeEnum.appointment) { + postVoice = ticket.ticketModel!.callTypeEnum.getCallTextFromCallType(ticket.ticketModel!); + } else { + postVoice = ticket.ticketModel!.postVoiceText; + } + String roomNo = ''; + if (globalConfigurationsModel.qTypeEnum != QTypeEnum.appointment && ticket.ticketModel!.roomNo.isNotEmpty) { + roomNo = " ${ticket.ticketModel!.roomNo.toString()}"; + } + + String ticketNo = ticket.ticketModel!.queueNo.trim().toString(); + String message = "$preVoice $ticketNo $postVoice $roomNo"; + + // Get language + final lang = ticket.ticketModel!.voiceLanguageEnum == LanguageEnum.arabic ? 'ar-SA' : 'en-US'; + + // Create utterance + final utterance = html.SpeechSynthesisUtterance(message); + utterance.lang = lang; + utterance.rate = 0.9; + utterance.pitch = 1.0; + utterance.volume = 1.0; + + // Cancel any ongoing speech + synthesis.cancel(); + + // Speak + synthesis.speak(utterance); + + log('Web TTS: Speaking "$message" in $lang'); + } catch (e) { + log('Web TTS Error: $e'); + } + } + + // Test function to speak any text + @override + Future testSpeak(String text, {LanguageEnum language = LanguageEnum.english}) async { + log('๐ŸŽค TEST TTS: Speaking "$text" in ${language.name}'); + + if (kIsWeb) { + // Web implementation + try { + final synthesis = html.window.speechSynthesis; + if (synthesis == null) { + log('Web Speech API not supported'); + return; + } + + final lang = language == LanguageEnum.arabic ? 'ar-SA' : 'en-US'; + final utterance = html.SpeechSynthesisUtterance(text); + utterance.lang = lang; + utterance.rate = 0.9; + utterance.pitch = 1.0; + utterance.volume = 1.0; + + synthesis.cancel(); + synthesis.speak(utterance); + + log('โœ… Web TTS Test: Spoke "$text" in $lang'); + } catch (e) { + log('โŒ Web TTS Test Error: $e'); + } + } else { + // Android implementation + try { + const ttsGoogleEngine = 'com.google.android.tts'; + List engines = await textToSpeechInstance.getEngines; + if (engines.contains(ttsGoogleEngine)) { + await textToSpeechInstance.setEngine(ttsGoogleEngine); + } + + await textToSpeechInstance.setLanguage(language.enumToString()); + textToSpeechInstance.setVolume(1.0); + textToSpeechInstance.setSpeechRate(0.5); + textToSpeechInstance.setPitch(0.9); + + await textToSpeechInstance.speak(text); + log('โœ… Android TTS Test: Spoke "$text" in ${language.name}'); + } catch (e) { + log('โŒ Android TTS Test Error: $e'); + } + } + } } diff --git a/lib/utilities/native_method_handler.dart b/lib/utilities/native_method_handler.dart index a3f2535..f333503 100644 --- a/lib/utilities/native_method_handler.dart +++ b/lib/utilities/native_method_handler.dart @@ -1,6 +1,8 @@ import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:hmg_qline/services/logger_service.dart'; import 'package:hmg_qline/utilities/enums.dart'; +import 'package:universal_html/html.dart' as html; abstract class NativeMethodChannelService { void reopenApp(); @@ -89,6 +91,14 @@ class NativeMethodChannelServiceImp implements NativeMethodChannelService { try { loggerService.logInfo("Starting smart restart - forceRestart: $forceRestart, cleanupFirst: $cleanupFirst"); + // Web: Reload the page + if (kIsWeb) { + loggerService.logInfo("Web: Reloading page"); + html.window.location.reload(); + return; + } + + // Android: Use native restart if (cleanupFirst) { await clearAllResources(); await Future.delayed(const Duration(seconds: 1)); @@ -159,6 +169,8 @@ class NativeMethodChannelServiceImp implements NativeMethodChannelService { /// Returns true on Android < 12 (always allowed) or if permission is granted on Android 12+. @override Future canScheduleExactAlarms() async { + if (kIsWeb) return false; // Not applicable on web + try { final result = await _platform.invokeMethod('canScheduleExactAlarms'); loggerService.logInfo("Can schedule exact alarms: $result"); diff --git a/lib/view_models/queuing_view_model.dart b/lib/view_models/queuing_view_model.dart index 1bd1ad3..66e55a4 100644 --- a/lib/view_models/queuing_view_model.dart +++ b/lib/view_models/queuing_view_model.dart @@ -34,6 +34,11 @@ class QueuingViewModel extends ChangeNotifier { Future initializeQueueingVM() async { try { + // Mark as disconnected initially + ScreenConfigViewModel screenConfigViewModel = getIt.get(); + screenConfigViewModel.updateIsHubConnected(false); + + // Attempt to connect (will update status based on result) await startHubConnection(); initializeAudioPlayer(); initializeTextToSpeech(); @@ -45,7 +50,7 @@ class QueuingViewModel extends ChangeNotifier { Future startHubConnection() async { try { ScreenConfigViewModel screenConfigViewModel = getIt.get(); - return await signalrRepo.startHubConnection( + final result = await signalrRepo.startHubConnection( deviceIp: screenConfigViewModel.currentScreenIP, onHubTicketCall: onHubTicketCall, onHubConfigCall: onHubConfigCall, @@ -53,7 +58,30 @@ class QueuingViewModel extends ChangeNotifier { onHubDisconnected: onHubDisconnected, onHubConnecting: onHubConnecting, ); + + // Update hub connection status based on result + if (result == true) { + screenConfigViewModel.updateIsHubConnected(true); + loggerService.logToFile( + message: "Hub connection successful", + source: "startHubConnection -> queuing_view_model.dart", + type: LogTypeEnum.connectivity, + ); + } else { + screenConfigViewModel.updateIsHubConnected(false); + loggerService.logToFile( + message: "Hub connection failed - result: $result", + source: "startHubConnection -> queuing_view_model.dart", + type: LogTypeEnum.connectivity, + ); + } + + return result; } catch (e, st) { + // On error, mark as disconnected + ScreenConfigViewModel screenConfigViewModel = getIt.get(); + screenConfigViewModel.updateIsHubConnected(false); + await getIt.get().handleCrash(error: e, stackTrace: st, context: 'startHubConnection -> queuing_view_model.dart'); return false; } diff --git a/lib/view_models/screen_config_view_model.dart b/lib/view_models/screen_config_view_model.dart index e08cc8f..c148b3f 100644 --- a/lib/view_models/screen_config_view_model.dart +++ b/lib/view_models/screen_config_view_model.dart @@ -23,8 +23,9 @@ import 'package:hmg_qline/utilities/extensions.dart'; import 'package:hmg_qline/utilities/native_method_handler.dart'; import 'package:hmg_qline/view_models/queuing_view_model.dart'; import 'package:hmg_qline/views/view_helpers/info_components.dart'; -import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:universal_html/html.dart' as html; // import 'package:timezone/browser.dart' as tz; class ScreenConfigViewModel extends ChangeNotifier { @@ -49,12 +50,36 @@ class ScreenConfigViewModel extends ChangeNotifier { await getLastTimeLogsClearedFromCache(); listenNetworkConnectivity(); initializeTimer(); + + // Platform-specific lifecycle + if (kIsWeb) { + _initializeWebLifecycle(); + } + } + + StreamSubscription? _visibilitySubscription; + + void _initializeWebLifecycle() { + // Listen to page visibility changes + _visibilitySubscription = html.document.onVisibilityChange.listen((event) { + if (html.document.hidden!) { + onAppPaused(); + } else { + onAppResumed(); + } + }); + + // Prevent accidental tab close + html.window.onBeforeUnload.listen((event) { + event.preventDefault(); + }); } Future onAppResumed() async { final uptimeHours = DateTime.now().difference(appStartTime).inHours; loggerService.logToFile( - message: "[didChangeAppLifecycleState] : [onAppResumed] - App uptime: ${uptimeHours}h", + message: + "[didChangeAppLifecycleState] : [onAppResumed] - App uptime: ${uptimeHours}h", source: "onAppResumed -> screen_config_view_model.dart", type: LogTypeEnum.data, ); @@ -66,14 +91,50 @@ class ScreenConfigViewModel extends ChangeNotifier { loggerService.logError("Failed to enable wakelock on resume: $e"); } - // Verify connections are still alive + // On web, wait a bit for network to stabilize after tab becomes visible + if (kIsWeb) { + await Future.delayed(const Duration(milliseconds: 500)); + } + + // Verify connections are still alive and reconnect if needed syncHubConnectionState(); + + // On web, proactively try to reconnect if disconnected + if (kIsWeb && !isHubConnected) { + loggerService.logToFile( + message: "Web page resumed - attempting SignalR reconnection", + source: "onAppResumed -> screen_config_view_model.dart", + type: LogTypeEnum.connectivity, + ); + QueuingViewModel queuingViewModel = getIt.get(); + try { + // Try to stop existing connection (may be null/disconnected already) + try { + await queuingViewModel.stopHubConnection(); + } catch (e) { + log("Stop connection error (expected if already disconnected): $e"); + } + await Future.delayed(const Duration(seconds: 1)); + // Start fresh connection + final result = await queuingViewModel.startHubConnection(); + if (result == true) { + loggerService.logToFile( + message: "SignalR reconnected successfully on resume", + source: "onAppResumed -> screen_config_view_model.dart", + type: LogTypeEnum.connectivity, + ); + } + } catch (e) { + loggerService.logError("Failed to reconnect on resume: $e"); + } + } } Future onAppPaused() async { final uptimeHours = DateTime.now().difference(appStartTime).inHours; loggerService.logToFile( - message: "[didChangeAppLifecycleState] : [onAppPaused] - App uptime: ${uptimeHours}h - WARNING: App going to background!", + message: + "[didChangeAppLifecycleState] : [onAppPaused] - App uptime: ${uptimeHours}h - WARNING: App going to background!", source: "onAppPaused -> screen_config_view_model.dart", type: LogTypeEnum.data, ); @@ -82,19 +143,56 @@ class ScreenConfigViewModel extends ChangeNotifier { Future onAppDetached() async { final uptimeHours = DateTime.now().difference(appStartTime).inHours; loggerService.logToFile( - message: "[didChangeAppLifecycleState] : [onAppDetached] - App uptime: ${uptimeHours}h - CRITICAL: App being killed!", + message: + "[didChangeAppLifecycleState] : [onAppDetached] - App uptime: ${uptimeHours}h - CRITICAL: App being killed!", source: "onAppDetached -> screen_config_view_model.dart", type: LogTypeEnum.data, ); } Future waitForIPAndInitializeConfigVM() async { + int attempts = 0; + const maxAttempts = kIsWeb ? 3 : 30; + + // On web, check localStorage immediately on startup (before any detection) + if (kIsWeb && currentScreenIP == "") { + try { + final storedIp = html.window.localStorage['device_ip']; + if (storedIp != null && storedIp.isNotEmpty && storedIp != 'null') { + currentScreenIP = storedIp; + log('โœ… IP retrieved from localStorage on startup: $currentScreenIP'); + initializeScreenConfigVM(); + return; + } else { + log('โš ๏ธ No IP found in localStorage on startup (storedIp: "$storedIp")'); + } + } catch (e) { + log('โŒ Error accessing localStorage on startup: $e'); + loggerService.logError('localStorage access error: $e'); + } + } + while (currentScreenIP == "") { await getCurrentScreenIP(); if (currentScreenIP != "") { initializeScreenConfigVM(); + return; } else { - await Future.delayed(const Duration(seconds: 2)); + attempts++; + if (attempts >= maxAttempts) { + if (kIsWeb) { + // On web, show configuration dialog + loggerService.logError( + "No IP auto-detected or configured after $attempts attempts."); + throw Exception("IP_CONFIGURATION_REQUIRED"); + } else { + // On Android native, keep trying + loggerService.logError("Failed to get IP after $attempts attempts"); + } + } + // Wait 4 seconds on first attempt to allow auto-detection + final waitTime = attempts == 1 ? 4 : 2; + await Future.delayed(Duration(seconds: waitTime)); } } } @@ -106,7 +204,7 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); } - bool isHubConnected = true; + bool isHubConnected = false; // Start as disconnected until connection succeeds updateIsHubConnected(bool value) { isHubConnected = value; @@ -140,11 +238,33 @@ class ScreenConfigViewModel extends ChangeNotifier { String currentScreenIP = ""; Future getCurrentScreenIP() async { + + + log("Using TEST IP"); if (useTestIP) { currentScreenIP = AppConstants.testIP; + log("๐Ÿงช Using TEST IP: $currentScreenIP"); } else { currentScreenIP = await connectivityService.getCurrentScreenIP(); - log("currentScreenIP: $currentScreenIP"); + if (currentScreenIP.isEmpty) { + log("โš ๏ธ WARNING: No IP address detected!"); + if (kIsWeb) { + log("๐Ÿ“Œ For web, add IP to URL: ?ip=YOUR_IP_ADDRESS"); + log(" Example: http://localhost:port/?ip=12.4.5.1"); + } + } else { + log("โœ… Current Screen IP: $currentScreenIP"); + // On web, ensure IP is saved to localStorage + if (kIsWeb) { + try { + html.window.localStorage['device_ip'] = currentScreenIP; + log("๐Ÿ’พ Saved IP to localStorage: $currentScreenIP"); + } catch (e) { + log("โŒ Failed to save IP to localStorage: $e"); + loggerService.logError('localStorage save error: $e'); + } + } + } } } @@ -162,10 +282,15 @@ class ScreenConfigViewModel extends ChangeNotifier { ); } - GlobalConfigurationsModel globalConfigurationsModel = GlobalConfigurationsModel(); + GlobalConfigurationsModel globalConfigurationsModel = + GlobalConfigurationsModel(); Future getGlobalConfigurationsByIP() async { - GlobalConfigurationsModel? response = await screenDetailsRepo.getGlobalScreenConfigurations(ipAddress: currentScreenIP); + + GlobalConfigurationsModel? response = await screenDetailsRepo + .getGlobalScreenConfigurations(ipAddress: currentScreenIP); + + loggerService.logError("response was: $response"); if (response == null) { loggerService.logError("response was: $response"); @@ -179,12 +304,16 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); } - void updateGlobalConfigurationsModel({required var value, bool needNotify = false, bool shouldUpdateNextPrayer = false}) { + void updateGlobalConfigurationsModel( + {required var value, + bool needNotify = false, + bool shouldUpdateNextPrayer = false}) { // Ensure the incoming value is the GlobalConfigurationsModel and set the Takhasusi flag based on projectID if (value is GlobalConfigurationsModel) { // Use projectID-based detection (projectID = 16 for Takhasusi Main) try { - value.isFromTakhasusiMain = value.projectID == AppConstants.projectIdTakhasusiMain; + value.isFromTakhasusiMain = + value.projectID == AppConstants.projectIdTakhasusiMain; // Debug logging for Takhasusi Main detection log("=== TAKHASUSI MAIN CHECK (ProjectID-based) ==="); @@ -227,13 +356,15 @@ class ScreenConfigViewModel extends ChangeNotifier { } int currentDate = DateTime.now().millisecondsSinceEpoch; - await cacheService.setLastTimeUpdatedInCache(lasTimeUpdated: currentDate.toString()); + await cacheService.setLastTimeUpdatedInCache( + lasTimeUpdated: currentDate.toString()); } RssFeedModel rssFeedModel = RssFeedModel(); Future getRssFeedDetailsFromServer() async { - RssFeedModel? response = await screenDetailsRepo.getRssFeedDetailsByLanguageID(languageId: 0); + RssFeedModel? response = + await screenDetailsRepo.getRssFeedDetailsByLanguageID(languageId: 0); if (response == null) { return; @@ -247,9 +378,14 @@ class ScreenConfigViewModel extends ChangeNotifier { Future getPrayerDetailsFromServer() async { double testLatitude = 24.722136; double testLongitude = 46.774303; - PrayersWidgetModel? response = await screenDetailsRepo.getPrayerDetailsByLatLong( - latitude: globalConfigurationsModel.projectLatitude == 0.0 ? testLatitude : globalConfigurationsModel.projectLatitude ?? testLatitude, - longitude: globalConfigurationsModel.projectLongitude == 0.0 ? testLongitude : globalConfigurationsModel.projectLongitude ?? testLongitude, + PrayersWidgetModel? response = + await screenDetailsRepo.getPrayerDetailsByLatLong( + latitude: globalConfigurationsModel.projectLatitude == 0.0 + ? testLatitude + : globalConfigurationsModel.projectLatitude ?? testLatitude, + longitude: globalConfigurationsModel.projectLongitude == 0.0 + ? testLongitude + : globalConfigurationsModel.projectLongitude ?? testLongitude, ); if (response == null) { @@ -265,10 +401,13 @@ class ScreenConfigViewModel extends ChangeNotifier { Future getWeatherDetailsFromServer() async { int testCityKey = 297030; - WeathersWidgetModel? response = await screenDetailsRepo.getWeatherDetailsByCity( - cityId: - ((globalConfigurationsModel.cityKey == null || globalConfigurationsModel.cityKey == 0) ? testCityKey : globalConfigurationsModel.cityKey) - .toString(), + WeathersWidgetModel? response = + await screenDetailsRepo.getWeatherDetailsByCity( + cityId: ((globalConfigurationsModel.cityKey == null || + globalConfigurationsModel.cityKey == 0) + ? testCityKey + : globalConfigurationsModel.cityKey) + .toString(), ); if (response == null) { @@ -289,7 +428,8 @@ class ScreenConfigViewModel extends ChangeNotifier { // log("Data Before Check : ${prayersWidgetModel.fajr}"); // log("Data Before Check : ${globalConfigurationsModel.isPrayerTimeReq}"); - if (globalConfigurationsModel.isPrayerTimeReq && prayersWidgetModel.fajr == null) { + if (globalConfigurationsModel.isPrayerTimeReq && + prayersWidgetModel.fajr == null) { await getPrayerDetailsFromServer(); } @@ -298,7 +438,8 @@ class ScreenConfigViewModel extends ChangeNotifier { // log("current: ${DateFormat('yyyy-MM-dd hh:mm a').format(DateTime.now().toLocal())}"); // log("current: ${DateTime.now().timeZoneName}"); - if (prayersWidgetModel.fajr != null && prayersWidgetModel.fajr!.toDateTimeFromInt().isAfter(current)) { + if (prayersWidgetModel.fajr != null && + prayersWidgetModel.fajr!.toDateTimeFromInt().isAfter(current)) { final namazTime = prayersWidgetModel.fajr!.toFormattedDateTimeFromInt(); nextPrayerTime = namazTime; nextPrayerToShowEng = "${globalConfigurationsModel.fajarTextEng}"; @@ -306,7 +447,8 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); return; } - if (prayersWidgetModel.dhuhr != null && prayersWidgetModel.dhuhr!.toDateTimeFromInt().isAfter(current)) { + if (prayersWidgetModel.dhuhr != null && + prayersWidgetModel.dhuhr!.toDateTimeFromInt().isAfter(current)) { final namazTime = prayersWidgetModel.dhuhr!.toFormattedDateTimeFromInt(); nextPrayerTime = namazTime; nextPrayerToShowEng = "${globalConfigurationsModel.dhuhrTextEng}"; @@ -315,7 +457,8 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); return; } - if (prayersWidgetModel.asr != null && prayersWidgetModel.asr!.toDateTimeFromInt().isAfter(current)) { + if (prayersWidgetModel.asr != null && + prayersWidgetModel.asr!.toDateTimeFromInt().isAfter(current)) { final namazTime = prayersWidgetModel.asr!.toFormattedDateTimeFromInt(); nextPrayerTime = namazTime; nextPrayerToShowEng = "${globalConfigurationsModel.asarTextEng}"; @@ -324,8 +467,10 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); return; } - if (prayersWidgetModel.maghrib != null && prayersWidgetModel.maghrib!.toDateTimeFromInt().isAfter(current)) { - final namazTime = prayersWidgetModel.maghrib!.toFormattedDateTimeFromInt(); + if (prayersWidgetModel.maghrib != null && + prayersWidgetModel.maghrib!.toDateTimeFromInt().isAfter(current)) { + final namazTime = + prayersWidgetModel.maghrib!.toFormattedDateTimeFromInt(); nextPrayerTime = namazTime; nextPrayerToShowEng = "${globalConfigurationsModel.maghribTextEng}"; nextPrayerToShowArb = "${globalConfigurationsModel.maghribTextArb}"; @@ -333,7 +478,8 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); return; } - if (prayersWidgetModel.isha != null && prayersWidgetModel.isha!.toDateTimeFromInt().isAfter(current)) { + if (prayersWidgetModel.isha != null && + prayersWidgetModel.isha!.toDateTimeFromInt().isAfter(current)) { final namazTime = prayersWidgetModel.isha!.toFormattedDateTimeFromInt(); nextPrayerTime = namazTime; nextPrayerToShowEng = "${globalConfigurationsModel.ishaTextEng}"; @@ -363,7 +509,9 @@ class ScreenConfigViewModel extends ChangeNotifier { _midnightTimer?.cancel(); _healthCheckTimer?.cancel(); - if (!(globalConfigurationsModel.isWeatherReq) && !(globalConfigurationsModel.isPrayerTimeReq) && !(globalConfigurationsModel.isRssFeedReq)) { + if (!(globalConfigurationsModel.isWeatherReq) && + !(globalConfigurationsModel.isPrayerTimeReq) && + !(globalConfigurationsModel.isRssFeedReq)) { // Even if widgets are not required, start a minimal health check _startHealthCheckTimer(); return; @@ -391,8 +539,17 @@ class ScreenConfigViewModel extends ChangeNotifier { // At midnight, update weather and prayer details if required if (now.day != lastChecked.day) { - if (now.difference(now.copyWith(hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0)).inMinutes >= 5) { - await nativeMethodChannelService.smartRestart(forceRestart: true, cleanupFirst: true); + if (now + .difference(now.copyWith( + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0)) + .inMinutes >= + 5) { + await nativeMethodChannelService.smartRestart( + forceRestart: true, cleanupFirst: true); lastChecked = now; } } @@ -418,7 +575,8 @@ class ScreenConfigViewModel extends ChangeNotifier { log("Health check - App uptime: ${uptimeHours}h ${uptimeMinutes}m"); loggerService.logToFile( - message: "Health check performed - App uptime: ${uptimeHours}h ${uptimeMinutes}m (started: ${appStartTime.toString().substring(0, 19)})", + message: + "Health check performed - App uptime: ${uptimeHours}h ${uptimeMinutes}m (started: ${appStartTime.toString().substring(0, 19)})", source: "_startHealthCheckTimer -> screen_config_view_model.dart", type: LogTypeEnum.data, ); @@ -426,7 +584,8 @@ class ScreenConfigViewModel extends ChangeNotifier { // Check if app has been running for dangerously long (>60 hours) if (uptimeHours > 60) { loggerService.logToFile( - message: "WARNING: App running for ${uptimeHours} hours - scheduling proactive restart", + message: + "WARNING: App running for ${uptimeHours} hours - scheduling proactive restart", source: "_startHealthCheckTimer -> screen_config_view_model.dart", type: LogTypeEnum.data, ); @@ -435,20 +594,47 @@ class ScreenConfigViewModel extends ChangeNotifier { // Sync hub connection state try { syncHubConnectionState(); + + // On web, if disconnected for 2 consecutive checks, force reconnection + if (kIsWeb && !isHubConnected && healthCheckFailures >= 2) { + loggerService.logToFile( + message: + "Web disconnected for ${healthCheckFailures} checks - forcing reconnection", + source: + "_startHealthCheckTimer -> screen_config_view_model.dart", + type: LogTypeEnum.connectivity, + ); + QueuingViewModel queuingViewModel = getIt.get(); + try { + await queuingViewModel.stopHubConnection(); + } catch (e) { + log("Stop connection error during health check (may be expected): $e"); + } + await Future.delayed(const Duration(seconds: 2)); + await queuingViewModel.startHubConnection(); + } + healthCheckFailures = 0; // Reset on success } catch (e) { healthCheckFailures++; - loggerService.logError("Health check - Hub sync failed (${healthCheckFailures} consecutive): $e"); + loggerService.logError( + "Health check - Hub sync failed (${healthCheckFailures} consecutive): $e"); // If multiple consecutive failures, try to recover if (healthCheckFailures >= 3) { loggerService.logToFile( - message: "CRITICAL: ${healthCheckFailures} consecutive health check failures - attempting recovery", - source: "_startHealthCheckTimer -> screen_config_view_model.dart", + message: + "CRITICAL: ${healthCheckFailures} consecutive health check failures - attempting recovery", + source: + "_startHealthCheckTimer -> screen_config_view_model.dart", type: LogTypeEnum.data, ); QueuingViewModel queuingViewModel = getIt.get(); - await queuingViewModel.stopHubConnection(); + try { + await queuingViewModel.stopHubConnection(); + } catch (e) { + log("Stop connection error during recovery (may be expected): $e"); + } await Future.delayed(const Duration(seconds: 2)); await queuingViewModel.startHubConnection(); } @@ -463,7 +649,8 @@ class ScreenConfigViewModel extends ChangeNotifier { // Log memory/performance indicators loggerService.logToFile( - message: "Health check - Hub connected: $isHubConnected, Internet: $isInternetConnected", + message: + "Health check - Hub connected: $isHubConnected, Internet: $isInternetConnected", source: "_startHealthCheckTimer -> screen_config_view_model.dart", type: LogTypeEnum.connectivity, ); @@ -472,12 +659,14 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); } catch (e, stackTrace) { healthCheckFailures++; - loggerService.logError("Health check timer error (${healthCheckFailures} consecutive): $e\n$stackTrace"); + loggerService.logError( + "Health check timer error (${healthCheckFailures} consecutive): $e\n$stackTrace"); // If health check itself is failing repeatedly, something is seriously wrong if (healthCheckFailures >= 5) { loggerService.logToFile( - message: "FATAL: Health check failing repeatedly - app may be in bad state", + message: + "FATAL: Health check failing repeatedly - app may be in bad state", source: "_startHealthCheckTimer -> screen_config_view_model.dart", type: LogTypeEnum.data, ); @@ -489,9 +678,11 @@ class ScreenConfigViewModel extends ChangeNotifier { @override void dispose() { + _visibilitySubscription?.cancel(); _midnightTimer?.cancel(); _healthCheckTimer?.cancel(); patientIdController.dispose(); + mobileScannerController?.dispose(); super.dispose(); } @@ -511,14 +702,16 @@ class ScreenConfigViewModel extends ChangeNotifier { lastTimeLogsCleared = await cacheService.getLastTimeLogsCleared(); if (lastTimeLogsCleared == null) { await cacheService - .setLastTimeLogsCleared(lastTimeCleared: DateTime.now().millisecondsSinceEpoch) + .setLastTimeLogsCleared( + lastTimeCleared: DateTime.now().millisecondsSinceEpoch) .whenComplete(() => lastTimeLogsCleared = DateTime.now()); } } // *************************** KIOSK FUNCTIONS ************************* - KioskScreenStateEnums kioskScreenStateEnum = KioskScreenStateEnums.languageState; + KioskScreenStateEnums kioskScreenStateEnum = + KioskScreenStateEnums.languageState; void updateKioskScreenState(KioskScreenStateEnums state) { kioskScreenStateEnum = state; @@ -546,7 +739,8 @@ class ScreenConfigViewModel extends ChangeNotifier { notifyListeners(); } - Future generateTicketForQueue({required KioskQueueModel kioskQueueModel, int patientId = 0}) async { + Future generateTicketForQueue( + {required KioskQueueModel kioskQueueModel, int patientId = 0}) async { updateKioskScreenState(KioskScreenStateEnums.busyState); KioskPatientTicket? kioskPatientTicket = await createTicketFromKiosk( projectId: kioskQueueModel.projectID ?? 0, @@ -566,7 +760,8 @@ class ScreenConfigViewModel extends ChangeNotifier { int startTicket = 123457100; for (int i = 0; i < numOfTicketsToCreate; i++) { - GenericRespModel? response = await screenDetailsRepo.createTestTickets(ticketNumber: startTicket); + GenericRespModel? response = + await screenDetailsRepo.createTestTickets(ticketNumber: startTicket); startTicket = startTicket + 1; if (response == null || response.messageStatus != 1) { log("response null from createNextTickets"); @@ -577,9 +772,11 @@ class ScreenConfigViewModel extends ChangeNotifier { log("last ticket is: $startTicket "); } - Future createTicketFromKiosk({required int projectId, required int queueId, int patientId = 0}) async { + Future createTicketFromKiosk( + {required int projectId, required int queueId, int patientId = 0}) async { try { - GenericRespModel? response = await screenDetailsRepo.createTicketFromKiosk( + GenericRespModel? response = + await screenDetailsRepo.createTicketFromKiosk( projectId: projectId, queueId: queueId, patientId: patientId, @@ -596,7 +793,8 @@ class ScreenConfigViewModel extends ChangeNotifier { } } - Future acknowledgeTicket({required int ticketQueueID, required String ipAddress}) async { + Future acknowledgeTicket( + {required int ticketQueueID, required String ipAddress}) async { GenericRespModel? response = await screenDetailsRepo.acknowledgeTicket( ipAddress: ipAddress, ticketQueueID: ticketQueueID, @@ -611,17 +809,22 @@ class ScreenConfigViewModel extends ChangeNotifier { } Future acknowledgeTicketForAppointmentOnly( - {required int ticketQueueID, required String ipAddress, required CallTypeEnum callTypeEnum}) async { - GenericRespModel? response = await screenDetailsRepo.acknowledgeTicketForAppointment( + {required int ticketQueueID, + required String ipAddress, + required CallTypeEnum callTypeEnum}) async { + GenericRespModel? response = + await screenDetailsRepo.acknowledgeTicketForAppointment( ticketId: ticketQueueID, ipAddress: ipAddress, callTypeEnum: callTypeEnum, ); if (response == null || response.messageStatus != 1) { - loggerService.logError("response null from acknowledgeTicketForAppointmentOnly"); + loggerService + .logError("response null from acknowledgeTicketForAppointmentOnly"); return; } else { - loggerService.logInfo("response from acknowledgeTicketForAppointmentOnly ${response.data}"); + loggerService.logInfo( + "response from acknowledgeTicketForAppointmentOnly ${response.data}"); } } @@ -633,73 +836,52 @@ class ScreenConfigViewModel extends ChangeNotifier { int? patientId = int.tryParse(text); if (patientId != null && patientId > 0) { isGeneratingTicket = true; - await generateTicketForQueue(kioskQueueModel: currentSelectedKioskQueueModel, patientId: patientId); + await generateTicketForQueue( + kioskQueueModel: currentSelectedKioskQueueModel, + patientId: patientId); patientIdController.clear(); - await Future.delayed(const Duration(seconds: 2)).whenComplete(() => isGeneratingTicket = false); + await Future.delayed(const Duration(seconds: 2)) + .whenComplete(() => isGeneratingTicket = false); } } // *************************** QR SCANNER FUNCTIONS ************************* - Barcode? qrCodeResult; - QRViewController? qrViewController; + MobileScannerController? mobileScannerController; bool isGeneratingTicket = false; - Future flipCamera() async { - await qrViewController!.flipCamera(); + void initializeMobileScanner() { + mobileScannerController = MobileScannerController( + facing: kIsWeb ? CameraFacing.front : CameraFacing.back, + detectionSpeed: DetectionSpeed.normal, + ); } - void onQRViewCreated({ - required QRViewController controller, - required VoidCallback onSuccess, - required VoidCallback onFailure, - }) async { - qrViewController = controller; + void onQRDetected(BarcodeCapture barcodes) async { + if (isGeneratingTicket) return; - controller.scannedDataStream.listen((scanData) async { - loggerService.logInfo("Found: ${scanData.code} and isGeneratingTicket: $isGeneratingTicket"); - if (isGeneratingTicket) return; - await validateAndGenerateTicketFromQR(data: scanData, onFailure: onFailure, onSuccess: onSuccess); - }); - await flipCamera(); - } + final barcode = barcodes.barcodes.firstOrNull; + if (barcode == null || barcode.rawValue == null) return; - Future validateAndGenerateTicketFromQR({required var data, required VoidCallback onSuccess, required VoidCallback onFailure}) async { - if (data == null) return; - qrCodeResult = data; - notifyListeners(); + loggerService.logInfo( + "QR Found: ${barcode.rawValue} and isGeneratingTicket: $isGeneratingTicket"); - final code = qrCodeResult!.code; - int? patientId = int.tryParse(code ?? ''); + final patientId = int.tryParse(barcode.rawValue!); if (patientId != null && patientId > 0) { isGeneratingTicket = true; - bool status = await generateTicketForQueue(kioskQueueModel: currentSelectedKioskQueueModel, patientId: patientId); - qrCodeResult = null; - notifyListeners(); - - if (status) { - log("status: $status"); - onSuccess(); - } - - await Future.delayed(const Duration(seconds: 2)).whenComplete(() => isGeneratingTicket = false); - } - } - - Future reassemble() async { - if (qrViewController != null) { - if (defaultTargetPlatform == TargetPlatform.android) { - await qrViewController!.pauseCamera(); - } else if (defaultTargetPlatform == TargetPlatform.iOS) { - await qrViewController!.resumeCamera(); - } - await qrViewController!.flipCamera(); + await generateTicketForQueue( + kioskQueueModel: currentSelectedKioskQueueModel, + patientId: patientId, + ); + await Future.delayed(const Duration(seconds: 2)) + .whenComplete(() => isGeneratingTicket = false); } } void syncHubConnectionState() async { QueuingViewModel queuingViewModel = getIt.get(); - bool newState = (queuingViewModel.signalrRepo as SignalrRepoImp).isConnected; + bool newState = + (queuingViewModel.signalrRepo as SignalrRepoImp).isConnected; loggerService.logToFile( message: "Hub Current Status : $newState", @@ -713,8 +895,12 @@ class ScreenConfigViewModel extends ChangeNotifier { } if (!isHubConnected) { QueuingViewModel queuingViewModel = getIt.get(); - bool? status = await queuingViewModel.startHubConnection(); - // syncHubConnectionState will update isHubConnected + try { + await queuingViewModel.startHubConnection(); + // syncHubConnectionState will update isHubConnected + } catch (e) { + loggerService.logError("Failed to start hub connection in sync: $e"); + } } else { log("Hub is Connected!"); } diff --git a/lib/views/kiosk_screens/kiosk_qr_scanner_screen.dart b/lib/views/kiosk_screens/kiosk_qr_scanner_screen.dart index f484fa2..57a25a6 100644 --- a/lib/views/kiosk_screens/kiosk_qr_scanner_screen.dart +++ b/lib/views/kiosk_screens/kiosk_qr_scanner_screen.dart @@ -4,18 +4,31 @@ import 'package:hmg_qline/utilities/extensions.dart'; import 'package:hmg_qline/view_models/screen_config_view_model.dart'; import 'package:hmg_qline/views/view_helpers/size_config.dart'; import 'package:provider/provider.dart'; -import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; -class KioskQrScannerScreen extends StatelessWidget { - KioskQrScannerScreen({super.key}); +class KioskQrScannerScreen extends StatefulWidget { + const KioskQrScannerScreen({super.key}); - final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + @override + State createState() => _KioskQrScannerScreenState(); +} + +class _KioskQrScannerScreenState extends State { + @override + void initState() { + super.initState(); + // Initialize scanner + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initializeMobileScanner(); + }); + } @override Widget build(BuildContext context) { final screenConfigViewModel = context.read(); final size = MediaQuery.of(context).size; final scanArea = (size.width < size.height ? size.width : size.height) * 0.6; + return Scaffold( body: SafeArea( child: Stack( @@ -24,22 +37,19 @@ class KioskQrScannerScreen extends StatelessWidget { children: [ Expanded( flex: 9, - child: QRView( - key: qrKey, - overlay: QrScannerOverlayShape( + child: MobileScanner( + controller: screenConfigViewModel.mobileScannerController, + onDetect: screenConfigViewModel.onQRDetected, + overlay: CustomPaint( + painter: ScannerOverlayPainter( borderColor: AppColors.redColor, borderRadius: 10, borderLength: 30, borderWidth: 10, cutOutSize: scanArea, ), - onQRViewCreated: (controller) { - screenConfigViewModel.onQRViewCreated( - controller: controller, - onSuccess: () => context.popScreen(), - onFailure: () {}, - ); - }), + ), + ), ), ], ), @@ -48,7 +58,7 @@ class KioskQrScannerScreen extends StatelessWidget { left: 0, right: 0, child: Container( - color: Colors.black.withOpacity(0.4), + color: Colors.black.withValues(alpha: 0.4), padding: const EdgeInsets.all(16), child: Align( alignment: Alignment.centerLeft, @@ -78,7 +88,7 @@ class KioskQrScannerScreen extends StatelessWidget { ), ), GestureDetector( - onTap: () => screenConfigViewModel.flipCamera(), + onTap: () => screenConfigViewModel.mobileScannerController?.switchCamera(), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -104,16 +114,8 @@ class KioskQrScannerScreen extends StatelessWidget { builder: (BuildContext context, ScreenConfigViewModel screenConfigViewModel, _) { String text = ""; - if (screenConfigViewModel.qrCodeResult != null) { - final code = screenConfigViewModel.qrCodeResult?.code; - - int? patientId = int.tryParse(code ?? ''); - - if (patientId != null && patientId > 0) { - text = "Patient ID: $patientId\nGenerating Ticket..."; - } else { - text = "Invalid QR Code. Please scan a valid patient ID."; - } + if (screenConfigViewModel.isGeneratingTicket) { + text = "Generating Ticket..."; } else { text = "Please scan your Patient QR from Sulaiman Al Habib Mobile App"; } @@ -140,3 +142,108 @@ class KioskQrScannerScreen extends StatelessWidget { ); } } + +/// Custom painter for QR scanner overlay with corner borders +class ScannerOverlayPainter extends CustomPainter { + final Color borderColor; + final double borderRadius; + final double borderLength; + final double borderWidth; + final double cutOutSize; + + ScannerOverlayPainter({ + required this.borderColor, + required this.borderRadius, + required this.borderLength, + required this.borderWidth, + required this.cutOutSize, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.black.withValues(alpha: 0.5) + ..style = PaintingStyle.fill; + + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth; + + final centerX = size.width / 2; + final centerY = size.height / 2; + final halfCutOut = cutOutSize / 2; + + final cutOutRect = Rect.fromCenter( + center: Offset(centerX, centerY), + width: cutOutSize, + height: cutOutSize, + ); + + // Draw overlay with cutout + final overlayPath = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) + ..addRRect(RRect.fromRectAndRadius(cutOutRect, Radius.circular(borderRadius))) + ..fillType = PathFillType.evenOdd; + + canvas.drawPath(overlayPath, paint); + + // Draw corner borders + final left = centerX - halfCutOut; + final top = centerY - halfCutOut; + final right = centerX + halfCutOut; + final bottom = centerY + halfCutOut; + + // Top-left corner + canvas.drawLine( + Offset(left, top + borderRadius), + Offset(left, top + borderRadius + borderLength), + borderPaint, + ); + canvas.drawLine( + Offset(left + borderRadius, top), + Offset(left + borderRadius + borderLength, top), + borderPaint, + ); + + // Top-right corner + canvas.drawLine( + Offset(right, top + borderRadius), + Offset(right, top + borderRadius + borderLength), + borderPaint, + ); + canvas.drawLine( + Offset(right - borderRadius, top), + Offset(right - borderRadius - borderLength, top), + borderPaint, + ); + + // Bottom-left corner + canvas.drawLine( + Offset(left, bottom - borderRadius), + Offset(left, bottom - borderRadius - borderLength), + borderPaint, + ); + canvas.drawLine( + Offset(left + borderRadius, bottom), + Offset(left + borderRadius + borderLength, bottom), + borderPaint, + ); + + // Bottom-right corner + canvas.drawLine( + Offset(right, bottom - borderRadius), + Offset(right, bottom - borderRadius - borderLength), + borderPaint, + ); + canvas.drawLine( + Offset(right - borderRadius, bottom), + Offset(right - borderRadius - borderLength, bottom), + borderPaint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + diff --git a/lib/views/main_queue_screen/main_queue_screen.dart b/lib/views/main_queue_screen/main_queue_screen.dart index 529f8f6..0b2f468 100644 --- a/lib/views/main_queue_screen/main_queue_screen.dart +++ b/lib/views/main_queue_screen/main_queue_screen.dart @@ -29,7 +29,8 @@ class _MainQueueScreenState extends State { @override void initState() { super.initState(); - final ScreenConfigViewModel screenConfigViewModel = context.read(); + final ScreenConfigViewModel screenConfigViewModel = + context.read(); lifecycleHandler = LifecycleHandler( onResumed: () => screenConfigViewModel.onAppResumed(), onPaused: () => screenConfigViewModel.onAppPaused(), @@ -46,9 +47,16 @@ class _MainQueueScreenState extends State { Widget dataContent({required BuildContext context}) { return Consumer2( - builder: (BuildContext context, ScreenConfigViewModel screenConfigViewModel, QueuingViewModel queuingViewModel, Widget? child) { + builder: (BuildContext context, + ScreenConfigViewModel screenConfigViewModel, + QueuingViewModel queuingViewModel, + Widget? child) { // - // // TODO: FOR TEST + // TODO: FOR TEST - Test TTS with number 50 + // queuingViewModel.textToSpeechService.testSpeak("Ticket Number.. A-21. Please visit Doctor", language: LanguageEnum.english); + queuingViewModel.textToSpeechService.testSpeak("ู…ุฑุญุจุง ุจูƒุŒ ู‡ุฐุง ู†ุต ุนุฑุจูŠ ู„ู„ุชุฌุฑุจุฉ", language: LanguageEnum.arabic); + + // // TODO: FOR TEST - Original ticket speech test // queuingViewModel.textToSpeechService.speechText( // globalConfigurationsModel: screenConfigViewModel.globalConfigurationsModel, // ticket: queuingViewModel.currentTickets.first, @@ -74,7 +82,9 @@ class _MainQueueScreenState extends State { if (screenConfigViewModel.currentQTypeEnum == QTypeEnum.general) { text = AppStrings.awaitingQueueNumberEng; } - if (screenConfigViewModel.globalConfigurationsModel.screenLanguageEnum == LanguageEnum.arabic) { + if (screenConfigViewModel + .globalConfigurationsModel.screenLanguageEnum == + LanguageEnum.arabic) { if (screenConfigViewModel.currentQTypeEnum == QTypeEnum.general) { text = AppStrings.awaitingQueueNumberAr; } @@ -82,7 +92,8 @@ class _MainQueueScreenState extends State { fontFamily = AppStrings.fontNameCairo; } - if (screenConfigViewModel.state == ViewState.error && queuingViewModel.currentTickets.isEmpty) { + if (screenConfigViewModel.state == ViewState.error && + queuingViewModel.currentTickets.isEmpty) { widget = intimationWidget( text: AppStrings.configurationIssueContactAdmin, fontName: AppStrings.fontNamePoppins, @@ -93,16 +104,25 @@ class _MainQueueScreenState extends State { roomText: '', ); } else if (queuingViewModel.currentTickets.isEmpty) { - bool isForRoomLevel = screenConfigViewModel.globalConfigurationsModel.screenTypeEnum == ScreenTypeEnum.roomLevelScreen; + bool isForRoomLevel = + screenConfigViewModel.globalConfigurationsModel.screenTypeEnum == + ScreenTypeEnum.roomLevelScreen; widget = intimationWidget( text: text, fontName: fontFamily, isForRoomLevel: isForRoomLevel, - isRoomNoRequired: screenConfigViewModel.globalConfigurationsModel.isRoomNoRequired ?? true, - counterNo: screenConfigViewModel.globalConfigurationsModel.roomNo ?? 0, - roomText: screenConfigViewModel.globalConfigurationsModel.roomTextEng ?? AppStrings.counterNo, + isRoomNoRequired: screenConfigViewModel + .globalConfigurationsModel.isRoomNoRequired ?? + true, + counterNo: + screenConfigViewModel.globalConfigurationsModel.roomNo ?? 0, + roomText: + screenConfigViewModel.globalConfigurationsModel.roomTextEng ?? + AppStrings.counterNo, ); - } else if (screenConfigViewModel.globalConfigurationsModel.screenTypeEnum == ScreenTypeEnum.roomLevelScreen) { + } else if (screenConfigViewModel + .globalConfigurationsModel.screenTypeEnum == + ScreenTypeEnum.roomLevelScreen) { widget = Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -111,13 +131,20 @@ class _MainQueueScreenState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ counterNoText( - counterNo: screenConfigViewModel.globalConfigurationsModel.roomNo ?? 0, - roomText: screenConfigViewModel.globalConfigurationsModel.roomTextEng ?? AppStrings.counterNo, - isRoomNoRequired: screenConfigViewModel.globalConfigurationsModel.isRoomNoRequired ?? true, + counterNo: screenConfigViewModel + .globalConfigurationsModel.roomNo ?? + 0, + roomText: screenConfigViewModel + .globalConfigurationsModel.roomTextEng ?? + AppStrings.counterNo, + isRoomNoRequired: screenConfigViewModel + .globalConfigurationsModel.isRoomNoRequired ?? + true, ), PriorityTickets( tickets: [queuingViewModel.currentTickets.first], - globalConfigurationsModel: screenConfigViewModel.globalConfigurationsModel, + globalConfigurationsModel: + screenConfigViewModel.globalConfigurationsModel, ), const SizedBox.shrink() ], @@ -127,9 +154,12 @@ class _MainQueueScreenState extends State { } else { widget = PriorityTicketsWithSideSection( tickets: queuingViewModel.currentTickets, - globalConfigurationsModel: screenConfigViewModel.globalConfigurationsModel, - screenOrientationEnum: screenConfigViewModel.globalConfigurationsModel.orientationTypeEnum, - showThankyouMessage: queuingViewModel.currentTickets.length <= AppConstants.thresholdForListUI, + globalConfigurationsModel: + screenConfigViewModel.globalConfigurationsModel, + screenOrientationEnum: screenConfigViewModel + .globalConfigurationsModel.orientationTypeEnum, + showThankyouMessage: queuingViewModel.currentTickets.length <= + AppConstants.thresholdForListUI, // showThankyouMessage: true, ); } @@ -147,13 +177,15 @@ class _MainQueueScreenState extends State { Widget getBody({required BuildContext context}) { return Consumer( - builder: (BuildContext context, ScreenConfigViewModel screenConfigVM, Widget? child) { + builder: (BuildContext context, ScreenConfigViewModel screenConfigVM, + Widget? child) { return Column( children: [ Expanded( flex: 10, child: Padding( - padding: EdgeInsets.symmetric(horizontal: SizeConfig.getWidthMultiplier()! * 4), + padding: EdgeInsets.symmetric( + horizontal: SizeConfig.getWidthMultiplier()! * 4), child: dataContent(context: context), ), ), @@ -165,18 +197,21 @@ class _MainQueueScreenState extends State { @override Widget build(BuildContext context) { - final globalConfigurationsModel = context.read().globalConfigurationsModel; + final globalConfigurationsModel = + context.read().globalConfigurationsModel; log("isFromTakhasusiMain: ${globalConfigurationsModel.isFromTakhasusiMain}"); return Selector( - selector: (context, screenConfigViewModel) => screenConfigViewModel.globalConfigurationsModel.orientationTypeEnum, - builder: (BuildContext context, ScreenOrientationEnum screenOrientationEnum, Widget? child) { - + selector: (context, screenConfigViewModel) => + screenConfigViewModel.globalConfigurationsModel.orientationTypeEnum, + builder: (BuildContext context, + ScreenOrientationEnum screenOrientationEnum, Widget? child) { //TODO: For Testing Only log("=== ROTATEDBOX DEBUG ==="); log("ScreenOrientationEnum: $screenOrientationEnum"); log("isFromTakhasusiMain: ${globalConfigurationsModel.isFromTakhasusiMain}"); - final quarterTurns = - globalConfigurationsModel.isFromTakhasusiMain ? screenOrientationEnum.getTurnsByOrientationForOlderVersions() : screenOrientationEnum.getTurnsByOrientationForNewVersions(); + final quarterTurns = globalConfigurationsModel.isFromTakhasusiMain + ? screenOrientationEnum.getTurnsByOrientationForOlderVersions() + : screenOrientationEnum.getTurnsByOrientationForNewVersions(); log("QuarterTurns applied: $quarterTurns"); log("MediaQuery size: ${MediaQuery.of(context).size}"); log("MediaQuery orientation: ${MediaQuery.of(context).orientation}"); diff --git a/lib/views/splash_screen/splash_screen.dart b/lib/views/splash_screen/splash_screen.dart index a4b2885..d4bc489 100644 --- a/lib/views/splash_screen/splash_screen.dart +++ b/lib/views/splash_screen/splash_screen.dart @@ -1,45 +1,161 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:hmg_qline/config/routes.dart'; import 'package:hmg_qline/utilities/extensions.dart'; import 'package:hmg_qline/view_models/queuing_view_model.dart'; import 'package:hmg_qline/view_models/screen_config_view_model.dart'; +import 'package:hmg_qline/views/common_widgets/ip_config_dialog.dart'; import 'package:provider/provider.dart'; import 'package:hmg_qline/utilities/enums.dart'; import 'dart:developer'; +import 'package:universal_html/html.dart' as html; -class SplashScreen extends StatelessWidget { +class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); - Future _loadData(BuildContext context) async { - final screenConfigViewModel = context.read(); - final queuingViewModel = context.read(); - await screenConfigViewModel.waitForIPAndInitializeConfigVM(); - await queuingViewModel.initializeQueueingVM(); + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State { + Future? _initializationFuture; + bool _showIpDialog = false; + + @override + void initState() { + super.initState(); + _initializationFuture = _loadData(); + } + + Future _loadData() async { + try { + final screenConfigViewModel = context.read(); + final queuingViewModel = context.read(); + await screenConfigViewModel.waitForIPAndInitializeConfigVM(); + await queuingViewModel.initializeQueueingVM(); + } catch (e) { + if (e.toString().contains('IP_CONFIGURATION_REQUIRED')) { + setState(() => _showIpDialog = true); + rethrow; + } + rethrow; + } } - static Future? _initializationFuture; + void _reloadWithIp(String ip) { + if (kIsWeb) { + // Ensure IP is saved to localStorage + log('๐Ÿ’พ Attempting to save IP to localStorage: $ip'); + try { + html.window.localStorage['device_ip'] = ip; + final saved = html.window.localStorage['device_ip']; + log('๐Ÿ’พ Saved IP to localStorage in splash: $saved'); + log(' Verify: saved == ip: ${saved == ip}'); + log(' localStorage keys: ${html.window.localStorage.keys.toList()}'); + + if (saved != ip) { + log('โš ๏ธ WARNING: Verification mismatch! Expected: $ip, Got: $saved'); + } + } catch (e) { + log('โŒ Error saving to localStorage: $e'); + } + } + + final screenConfigViewModel = context.read(); + screenConfigViewModel.currentScreenIP = ip; + log('โœ… IP configured in viewModel: ${screenConfigViewModel.currentScreenIP}'); + log('โœ… IP configured: $ip - Reloading...'); + + setState(() { + _showIpDialog = false; + _initializationFuture = _loadData(); + }); + } @override Widget build(BuildContext context) { - _initializationFuture ??= _loadData(context); return FutureBuilder( future: _initializationFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Scaffold( + return Scaffold( body: Center( - child: CircularProgressIndicator(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + kIsWeb ? 'Detecting device IP...' : 'Initializing...', + style: const TextStyle(fontSize: 16), + ), + if (kIsWeb) const SizedBox(height: 8), + if (kIsWeb) + const Text( + 'Please wait', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), ), ); } + // If an error occurs if (snapshot.hasError) { + final error = snapshot.error.toString(); + + // Check if error is due to missing IP on web + if (kIsWeb && + (error.contains('IP_CONFIGURATION_REQUIRED') || _showIpDialog)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_showIpDialog) { + setState(() => _showIpDialog = true); + _showIpConfigDialog(); + } + }); + return _buildIpConfigScreen(); + } + return Scaffold( body: Center( child: Padding( - padding: const EdgeInsets.all(12), - child: Text('${snapshot.stackTrace}'), - ), // Error message + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 64, + ), + const SizedBox(height: 16), + const Text( + 'Initialization Error', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + snapshot.error.toString(), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + setState(() { + _initializationFuture = _loadData(); + }); + }, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), ), ); } @@ -47,20 +163,83 @@ class SplashScreen extends StatelessWidget { // If the data is loaded successfully if (snapshot.connectionState == ConnectionState.done) { Future.delayed(const Duration(seconds: 1)).whenComplete(() { - if (context.read().currentScreenTypeEnum == ScreenTypeEnum.kioskScreen) { - context.navigateReplaceTo(AppRoutes.kioskMainScreen); - } else { - context.navigateReplaceTo(AppRoutes.mainQueueScreen); + if (mounted) { + if (context.read().currentScreenTypeEnum == + ScreenTypeEnum.kioskScreen) { + context.navigateReplaceTo(AppRoutes.kioskMainScreen); + } else { + context.navigateReplaceTo(AppRoutes.mainQueueScreen); + } } }); } return const Scaffold( body: Center( - child: CircularProgressIndicator(), // Loading indicator + child: CircularProgressIndicator(), ), ); }, ); } + + Widget _buildIpConfigScreen() { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.settings_ethernet, + size: 64, + color: Colors.blue, + ), + const SizedBox(height: 24), + const Text( + 'Device IP Configuration Required', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Please configure the device IP address to continue', + style: TextStyle(fontSize: 14, color: Colors.grey), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: _showIpConfigDialog, + icon: const Icon(Icons.router), + label: const Text('Configure IP Address'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + ), + ], + ), + ), + ); + } + + void _showIpConfigDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => IpConfigDialog( + onIpSubmitted: (ip) { + Navigator.of(context).pop(); + _reloadWithIp(ip); + }, + ), + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 6553eb6..506432b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,7 +45,15 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted version: "1.4.0" @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -216,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct overridden" description: @@ -240,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + js: + dependency: "direct main" + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" just_audio: dependency: "direct main" description: @@ -324,26 +356,34 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "1b60b8f9d4ce0cb0e7d7bc223c955d083a0737bee66fa1fcfe5de48225e0d5b3" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "3.5.7" nested: dependency: transitive description: @@ -472,14 +512,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5" - qr_code_scanner_plus: - dependency: "direct main" - description: - name: qr_code_scanner_plus - sha256: "39696b50d277097ee4d90d4292de36f38c66213a4f5216a06b2bdd2b63117859" - url: "https://pub.dev" - source: hosted - version: "2.0.10+1" rxdart: dependency: transitive description: @@ -625,10 +657,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" tuple: dependency: transitive description: @@ -645,6 +677,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_html: + dependency: "direct main" + description: + name: universal_html + sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" uuid: dependency: transitive description: @@ -758,5 +806,5 @@ packages: source: hosted version: "1.0.1" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index ae909e8..23b153f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: # http: ^1.2.2 blinking_text: ^1.0.2 just_audio: ^0.9.42 + flutter_tts: ^4.2.0 wakelock_plus: ^1.2.10 shared_preferences: ^2.3.5 @@ -51,11 +52,14 @@ dependencies: marquee: ^2.2.3 logger: ^2.4.0 fluttertoast: ^8.2.8 - qr_code_scanner_plus: ^2.0.10+1 + mobile_scanner: ^3.5.2 # Web-compatible QR scanner (replaces qr_code_scanner_plus) path_provider: ^2.1.5 flutter_foreground_task: ^9.1.0 # restart_app: ^1.3.2 zo_animated_border: ^1.0.1 + # Web support dependencies + universal_html: ^2.2.4 # For web APIs access + js: ^0.6.7 # For JavaScript interop # animated_flip_counter: ^0.3.4 # smooth_corner: ^1.1.1