diff --git a/frontend/lib/core/network/api_constants.dart b/frontend/lib/core/network/api_constants.dart index 602cd78..0e376f2 100644 --- a/frontend/lib/core/network/api_constants.dart +++ b/frontend/lib/core/network/api_constants.dart @@ -4,12 +4,13 @@ import 'package:shared_preferences/shared_preferences.dart'; class ApiConstants { static const int port = 18484; + static const String tunnelUrl = 'https://tis-rms.cc.cd/api'; static const String vpsUrl = 'http://198.252.107.197:$port/api'; static const String localhostUrl = 'http://127.0.0.1:$port/api'; // Runtime-mutable base URL — set by ServerDiscoveryService before first use. - // Default: remote internet/configured VPS server IP or domain. - static String _baseUrl = vpsUrl; + // Default: tunnel domain or discovered local server. + static String _baseUrl = tunnelUrl; static String get baseUrl => _baseUrl; @@ -38,7 +39,6 @@ class ApiConstants { InterceptorsWrapper( onRequest: (options, handler) { options.baseUrl = _baseUrl; - options.headers['Bypass-Tunnel-Reminder'] = 'true'; // Prevent unauthenticated or malformed token requests from hitting the server final authHeader = options.headers['Authorization']?.toString() ?? ''; diff --git a/frontend/lib/core/network/server_discovery.dart b/frontend/lib/core/network/server_discovery.dart index b987be5..ec27560 100644 --- a/frontend/lib/core/network/server_discovery.dart +++ b/frontend/lib/core/network/server_discovery.dart @@ -33,18 +33,30 @@ class ServerDiscoveryService { static Future ping(String baseUrl) async { try { - final rootUrl = baseUrl - .replaceAll(RegExp(r'/+$'), '') - .replaceAll(RegExp(r'/api$'), ''); + final clean = baseUrl.replaceAll(RegExp(r'/+$'), ''); final dio = Dio( BaseOptions( - baseUrl: rootUrl, connectTimeout: _pingTimeout, receiveTimeout: _pingTimeout, + sendTimeout: _pingTimeout, ), ); - final response = await dio.get('/'); - return response.headers.value('x-tis-rms') == 'true'; + final response = await dio.get(clean); + if (response.headers.value('x-tis-rms') == 'true' || + (response.statusCode == 200 && + response.data is Map && + response.data['message']?.toString().contains('TIS RMS') == true)) { + return true; + } + final rootUrl = clean.replaceAll(RegExp(r'/api$'), ''); + if (rootUrl.isNotEmpty && rootUrl != clean) { + final rootResponse = await dio.get(rootUrl); + return rootResponse.headers.value('x-tis-rms') == 'true' || + (rootResponse.statusCode == 200 && + rootResponse.data is Map && + rootResponse.data['message']?.toString().contains('TIS RMS') == true); + } + return response.statusCode == 200; } catch (_) { return false; } diff --git a/frontend/lib/ui/screens/login/login_screen.dart b/frontend/lib/ui/screens/login/login_screen.dart index d8c3ba2..f90e7c1 100644 --- a/frontend/lib/ui/screens/login/login_screen.dart +++ b/frontend/lib/ui/screens/login/login_screen.dart @@ -184,6 +184,7 @@ class _LoginScreenState extends ConsumerState with SingleTickerProv await prefs.remove('saved_password'); } + if (!mounted) return; final user = ref.read(authProvider).value; final isDesktop = MediaQuery.of(context).size.width >= 800; @@ -515,17 +516,26 @@ class _LoginScreenState extends ConsumerState with SingleTickerProv ], ), actions: [ + TextButton( + onPressed: () async { + ApiConstants.setBaseUrl(ApiConstants.tunnelUrl); + await ServerDiscoveryService.save(ApiConstants.baseUrl); + if (mounted) setState(() {}); + if (ctx.mounted) Navigator.pop(ctx); + }, + child: const Text('Use Tunnel'), + ), TextButton( onPressed: () async { ApiConstants.setBaseUrl(ApiConstants.vpsUrl); await ServerDiscoveryService.save(ApiConstants.baseUrl); if (mounted) setState(() {}); - Navigator.pop(ctx); + if (ctx.mounted) Navigator.pop(ctx); }, child: const Text('Use VPS'), ), TextButton( - onPressed: () async { + onPressed: () { Navigator.pop(ctx); showDialog( context: context, @@ -550,7 +560,7 @@ class _LoginScreenState extends ConsumerState with SingleTickerProv await ServerDiscoveryService.save(ApiConstants.baseUrl); if (mounted) setState(() {}); } - Navigator.pop(ctx); + if (ctx.mounted) Navigator.pop(ctx); }, child: const Text('Save'), ), @@ -623,7 +633,7 @@ class _LoginScreenState extends ConsumerState with SingleTickerProv 'Remember Me', style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], @@ -889,12 +899,26 @@ class _NetworkScanDialogState extends State<_NetworkScanDialog> { message: 'Connected to local server: $found', ); } else { - if (!mounted) return; - showErrorDialog( - context, - 'Server Not Found', - 'No TIS RMS server found on local network.', - ); + final tunnelAlive = + await ServerDiscoveryService.ping(ApiConstants.tunnelUrl); + if (tunnelAlive) { + ApiConstants.setBaseUrl(ApiConstants.tunnelUrl); + await ServerDiscoveryService.save(ApiConstants.baseUrl); + if (!mounted) return; + showSuccessDialog( + context, + title: 'Connected to Tunnel', + message: + 'Local server not found. Connected to tunnel domain: ${ApiConstants.tunnelUrl}', + ); + } else { + if (!mounted) return; + showErrorDialog( + context, + 'Server Not Found', + 'No TIS RMS server found on local network and tunnel domain (${ApiConstants.tunnelUrl}) is unreachable.', + ); + } } } diff --git a/frontend/lib/ui/screens/splash/splash_screen.dart b/frontend/lib/ui/screens/splash/splash_screen.dart index 40a9d13..4b9d7ff 100644 --- a/frontend/lib/ui/screens/splash/splash_screen.dart +++ b/frontend/lib/ui/screens/splash/splash_screen.dart @@ -1,5 +1,6 @@ -import 'dart:io' show Platform; +import 'dart:io' show Platform, exit; import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart' show SystemNavigator; import 'package:window_manager/window_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -85,7 +86,7 @@ class _SplashScreenState extends ConsumerState // ── Server resolution logic ──────────────────────────────────────────────── Future _resolveServer() async { - // 1. Check saved URL first (if user explicitly selected/saved a VPS or LAN IP) + // 1. Check saved URL first (if user explicitly selected/saved a VPS, LAN IP, or Tunnel URL) final saved = await ServerDiscoveryService.getSaved(); if (saved != null) { setState(() => _statusText = 'Connecting to saved server…'); @@ -94,8 +95,7 @@ class _SplashScreenState extends ConsumerState ApiConstants.setBaseUrl(saved); return; } - // Saved server is unreachable — clear it and continue discovery - await ServerDiscoveryService.clear(); + // Saved server is unreachable — don't wipe credentials, continue to LAN scan & tunnel fallback } // 2. Automatically scan local network first by default (LAN Discovery) @@ -103,7 +103,6 @@ class _SplashScreenState extends ConsumerState } Future _runScan() async { - // Get own IP / subnet prefixes first setState(() { _statusText = 'Detecting network…'; _scanProgress = null; @@ -111,51 +110,64 @@ class _SplashScreenState extends ConsumerState final prefixes = await ServerDiscoveryService.getSubnetPrefixes(); - if (prefixes.isEmpty) { - // No LAN interface found — go straight to manual entry - await _showManualEntryDialog( - reason: 'No Wi-Fi or LAN connection detected.', + if (prefixes.isNotEmpty) { + final subnetLabel = prefixes.map((p) => '${p}0/24').join(', '); + setState(() { + _statusText = 'Scanning LAN ($subnetLabel)…'; + _scanProgress = 0.0; + }); + + final found = await ServerDiscoveryService.discover( + onProgress: (subnet, scanned, total) { + if (!mounted) return; + setState(() { + _statusText = 'Scanning ${subnet}x … ($scanned/$total)'; + _scanProgress = scanned / total; + }); + }, ); - return; + + if (found != null) { + await ServerDiscoveryService.save(found); + ApiConstants.setBaseUrl(found); + if (mounted) { + setState(() { + _statusText = 'Local server found: $found'; + _scanProgress = 1.0; + }); + } + await Future.delayed(const Duration(milliseconds: 500)); + return; + } } - final subnetLabel = prefixes.map((p) => '${p}0/24').join(', '); + // 3. If local server was not found on LAN, connect to tunnel domain fallback setState(() { - _statusText = 'Scanning $subnetLabel…'; - _scanProgress = 0.0; + _statusText = 'Connecting to tunnel domain…'; + _scanProgress = null; }); - final found = await ServerDiscoveryService.discover( - onProgress: (subnet, scanned, total) { - if (!mounted) return; - setState(() { - _statusText = 'Scanning ${subnet}x … ($scanned/$total)'; - _scanProgress = scanned / total; - }); - }, - ); - - if (found != null) { - await ServerDiscoveryService.save(found); - ApiConstants.setBaseUrl(found); + final tunnelAlive = + await ServerDiscoveryService.ping(ApiConstants.tunnelUrl); + if (tunnelAlive) { + await ServerDiscoveryService.save(ApiConstants.tunnelUrl); + ApiConstants.setBaseUrl(ApiConstants.tunnelUrl); if (mounted) { setState(() { - _statusText = 'Server found: $found'; + _statusText = 'Connected to tunnel server'; _scanProgress = 1.0; }); } - await Future.delayed(const Duration(milliseconds: 600)); - } else { - await _showManualEntryDialog( - reason: 'No TIS RMS server was found on your network.', - ); + await Future.delayed(const Duration(milliseconds: 500)); + return; } + + // 4. If the tunnel domain is ALSO not working, show error dialog with Close App (without clearing remember me credentials) + if (!mounted) return; + await _showConnectionFailedDialog(); } - // ── Manual entry dialog ──────────────────────────────────────────────────── - - Future _showManualEntryDialog({required String reason}) async { - final controller = TextEditingController(); + Future _showConnectionFailedDialog() async { if (!mounted) return; await showDialog( @@ -164,68 +176,47 @@ class _SplashScreenState extends ConsumerState builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), icon: const Icon( - Icons.wifi_find, - color: AppColors.primaryGreen, - size: 36, + Icons.cloud_off_rounded, + color: Colors.redAccent, + size: 40, ), title: const Text( - 'Server Not Found', + 'Server Connection Failed', textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.bold), ), - content: Column( + content: const Column( mainAxisSize: MainAxisSize.min, children: [ Text( - reason, + 'No local TIS RMS server was detected on your network and the tunnel domain (${ApiConstants.tunnelUrl}) is currently unreachable.\n\nPlease check your network connection or ensure the server is online.', textAlign: TextAlign.center, - style: const TextStyle( + style: TextStyle( color: AppColors.textSecondary, fontSize: 13, + height: 1.4, ), ), - const SizedBox(height: 16), - TextField( - controller: controller, - keyboardType: TextInputType.url, - decoration: InputDecoration( - labelText: 'Server IP / Domain', - hintText: '198.252.107.197 or domain.com', - prefixIcon: const Icon(Icons.dns_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), + SizedBox(height: 12), + Text( + 'Your saved credentials remain safe.', + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.primaryGreen, + fontSize: 12, + fontWeight: FontWeight.w600, ), ), ], ), actions: [ TextButton( - onPressed: () async { - ServerDiscoveryService.save(ApiConstants.vpsUrl); - ApiConstants.setBaseUrl(ApiConstants.vpsUrl); + onPressed: () { Navigator.of(ctx).pop(); - }, - child: const Text('Use VPS (Testing)'), - ), - if (!kIsWeb && - (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) - TextButton( - onPressed: () async { - ServerDiscoveryService.save(ApiConstants.localhostUrl); - ApiConstants.setBaseUrl(ApiConstants.localhostUrl); - Navigator.of(ctx).pop(); - }, - child: const Text('Use Localhost'), - ), - TextButton( - onPressed: () async { - Navigator.of(ctx).pop(); - // Re-run scan - await _runScan(); + _initializeApp(); }, child: const Text( - 'Retry Scan', + 'Retry Connection', style: TextStyle( color: AppColors.primaryGreen, fontWeight: FontWeight.bold, @@ -234,28 +225,28 @@ class _SplashScreenState extends ConsumerState ), FilledButton( style: FilledButton.styleFrom( - backgroundColor: AppColors.primaryGreen, + backgroundColor: Colors.redAccent, ), onPressed: () { - final ip = controller.text.trim(); - if (ip.isNotEmpty) { - final url = ip.startsWith('http') - ? ip - : ip.contains(':') - ? 'http://$ip' - : 'http://$ip:${ApiConstants.port}'; - ServerDiscoveryService.save(url); - ApiConstants.setBaseUrl(url); - } - Navigator.of(ctx).pop(); + // Close app without clearing remember me credentials + _closeApp(); }, - child: const Text('Connect'), + child: const Text('Close App'), ), ], ), ); } + void _closeApp() { + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { + SystemNavigator.pop(); + } else if (!kIsWeb && + (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + exit(0); + } + } + // ── Build ────────────────────────────────────────────────────────────────── @override