feat: server discovery, tunnel fallback, and update configs

This commit is contained in:
ssbg04 committed 2026-10-09 01:31:10 +08:00
1 parent 59049f848d
commit 5abd5a812c
9 files changed
+610 -144

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
#define OutputBaseFilename "TIS_RMS_Client_Setup_v" + MyAppVersion #define OutputBaseFilename "TIS_RMS_Client_Setup_v" + MyAppVersion
#endif #endif
#define MyAppPublisher "Talisay Integrated School" #define MyAppPublisher "Talisay Integrated School"
#define MyAppURL "https://tis-rms.cc.cd" #define MyAppURL "https://web.tis-rms.cc.cd"
#define MyAppExeName "frontend.exe" #define MyAppExeName "frontend.exe"
#define MyAppIcon "windows\runner\resources\app_icon.ico" #define MyAppIcon "windows\runner\resources\app_icon.ico"
+113 -11
View File
@@ -1,5 +1,6 @@
import 'dart:io' show Platform; import 'dart:io' show Platform, HttpClient;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -32,7 +33,7 @@ class ApiConstants {
if (kIsWeb) return 'Web Browser'; if (kIsWeb) return 'Web Browser';
try { try {
if (Platform.isWindows) { if (Platform.isWindows) {
final hostname = Platform.localHostname; final hostname = Platform.localHostname.replaceAll(RegExp(r'[^\x20-\x7E]'), '').trim();
return hostname.isNotEmpty ? hostname : 'Windows PC'; return hostname.isNotEmpty ? hostname : 'Windows PC';
} }
if (Platform.isAndroid) return 'Android Device'; if (Platform.isAndroid) return 'Android Device';
@@ -43,6 +44,57 @@ class ApiConstants {
return 'Windows PC'; return 'Windows PC';
} }
/// Safely extracts user-friendly error message from any error/DioException.
/// Never throws NoSuchMethodError and prevents raw FormatException / HTML dumps.
static String extractErrorMessage(
dynamic error, [
String defaultMessage = 'Failed to connect to the server.',
]) {
if (error == null) return defaultMessage;
if (error is DioException) {
final res = error.response;
final data = res?.data;
if (data is Map && data['message'] != null) {
return data['message'].toString();
}
if (data is Map && data['error'] != null) {
return data['error'].toString();
}
if (data is String && data.isNotEmpty && !data.trimLeft().startsWith('<')) {
return data.length > 200 ? '${data.substring(0, 200)}…' : data;
}
final statusCode = res?.statusCode;
if (statusCode == 400) return 'Invalid request. Please check credentials or server URL.';
if (statusCode == 401) return 'Invalid username or password.';
if (statusCode == 403) return 'Access denied. Account may be inactive or lack permissions.';
if (statusCode == 404) return 'The requested API route was not found on this server (404).';
if (statusCode == 500) return 'Internal server error (500). Please try again later.';
if (statusCode == 502 || statusCode == 503 || statusCode == 504) {
return 'Server is temporarily unavailable (Gateway Error $statusCode).';
}
if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.receiveTimeout) {
return 'Connection timed out. Check your internet or server connection.';
}
if (error.type == DioExceptionType.connectionError) {
return 'Could not connect to server at $_baseUrl. Check your network connection.';
}
if (error.error is FormatException) {
return 'Server returned an invalid response (non-JSON). Check that server URL points to TIS RMS.';
}
if (error.message != null && error.message!.isNotEmpty && !error.message!.contains('Exception')) {
return error.message!;
}
}
if (error is FormatException) {
return 'Server returned an invalid response. Check that server URL points to TIS RMS.';
}
final raw = error.toString().replaceAll('Exception: ', '').replaceAll('FormatException: ', '').trim();
if (raw.contains('Unexpected character') || raw.startsWith('FormatUnexpected')) {
return 'Server returned an invalid response. Check that server URL points to TIS RMS.';
}
return raw.isNotEmpty ? raw : defaultMessage;
}
static void setBaseUrl(String url, {bool clearAuth = false}) { static void setBaseUrl(String url, {bool clearAuth = false}) {
// Strip trailing slash then append /api // Strip trailing slash then append /api
final clean = url.replaceAll(RegExp(r'/+$'), ''); final clean = url.replaceAll(RegExp(r'/+$'), '');
@@ -61,15 +113,61 @@ class ApiConstants {
} }
} }
/// Creates a Dio client that dynamically uses the active [baseUrl] on every request. /// Creates a Dio client that dynamically uses the active [baseUrl] on every request
/// with resilient timeouts and SSL certificate handling for Windows 10 and LAN.
static Dio createDio([BaseOptions? options]) { static Dio createDio([BaseOptions? options]) {
final dio = Dio(options ?? BaseOptions(baseUrl: _baseUrl)); final effectiveOptions = options ??
BaseOptions(
baseUrl: _baseUrl,
contentType: Headers.jsonContentType,
responseType: ResponseType.json,
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10),
);
// Ensure sensible timeouts and headers are set if custom options omitted them
effectiveOptions.connectTimeout ??= const Duration(seconds: 8);
effectiveOptions.receiveTimeout ??= const Duration(seconds: 15);
effectiveOptions.sendTimeout ??= const Duration(seconds: 10);
effectiveOptions.contentType ??= Headers.jsonContentType;
effectiveOptions.responseType ??= ResponseType.json;
final dio = Dio(effectiveOptions);
if (!kIsWeb) {
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 8);
client.badCertificateCallback = (cert, host, port) {
// Allow tis-rms domain and private/local network ranges on desktop/LAN
if (host.contains('tis-rms') ||
host == '127.0.0.1' ||
host == 'localhost' ||
host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.')) {
return true;
}
return false;
};
return client;
},
);
}
dio.interceptors.add( dio.interceptors.add(
InterceptorsWrapper( InterceptorsWrapper(
onRequest: (options, handler) { onRequest: (options, handler) {
options.baseUrl = _baseUrl; if (!options.path.startsWith('http://') &&
!options.path.startsWith('https://')) {
options.baseUrl = _baseUrl;
}
options.headers['X-Platform'] ??= clientPlatform; options.headers['X-Platform'] ??= clientPlatform;
options.headers['X-Device-Name'] ??= clientDeviceName; options.headers['X-Device-Name'] ??= clientDeviceName;
options.headers['Accept'] ??= 'application/json, text/plain, */*';
options.headers['User-Agent'] ??= 'TIS-RMS-Client/1.0 ($clientPlatform)';
// Prevent unauthenticated or malformed token requests from hitting the server // Prevent unauthenticated or malformed token requests from hitting the server
final authHeader = options.headers['Authorization']?.toString() ?? ''; final authHeader = options.headers['Authorization']?.toString() ?? '';
@@ -94,12 +192,16 @@ class ApiConstants {
if (statusCode == 401 || (statusCode == 403 && isDeactivated)) { if (statusCode == 401 || (statusCode == 403 && isDeactivated)) {
// Expire / wipe stored JWT token immediately on deactivation or invalidation // Expire / wipe stored JWT token immediately on deactivation or invalidation
const storage = FlutterSecureStorage(); try {
await storage.delete(key: 'jwt_token'); const storage = FlutterSecureStorage();
await storage.delete(key: 'remember_me'); await storage.delete(key: 'jwt_token');
final prefs = await SharedPreferences.getInstance(); await storage.delete(key: 'remember_me');
await prefs.remove('jwt_token'); } catch (_) {}
await prefs.remove('rememberMe'); try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('jwt_token');
await prefs.remove('rememberMe');
} catch (_) {}
} }
return handler.next(err); return handler.next(err);
+161 -88
View File
@@ -3,46 +3,55 @@ import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'api_constants.dart'; import 'api_constants.dart';
/// Discovers the TIS RMS server on the local network by: /// Discovers the TIS RMS server on the local network (LAN) and manages seamless fallback to Cloud Tunnel.
/// 1. Reading the device's own LAN IP to derive the subnet prefix. /// 1. Verifies physical network interfaces, filtering out virtual/bridge/loopback adapters.
/// 2. Concurrently probing all 254 host addresses on that subnet. /// 2. Probes local subnet hosts with bounded concurrency (pools of 25) using lightweight raw TCP checks first.
/// 3. Verifying each candidate by checking the X-TIS-RMS response header. /// 3. Confirms identity via HTTP X-TIS-RMS response header.
/// 4. If LAN connection is not found/working, automatically falls back to the Tunnel connection. /// 4. Prioritizes LAN for speed and offline resilience, seamlessly falling back to Cloud Tunnel (https://tis-rms.cc.cd/api).
class ServerDiscoveryService { class ServerDiscoveryService {
static const int _port = 18484; static const int _port = 18484;
static const String _prefsKey = 'server_url'; static const String _prefsKey = 'server_url';
static const Duration _probeTimeout = Duration(milliseconds: 800); static const Duration _socketTimeout = Duration(milliseconds: 300);
static const Duration _pingTimeout = Duration(seconds: 3); static const Duration _pingTimeout = Duration(seconds: 2);
// ─── Saved URL ──────────────────────────────────────────────────────────── // ─── Saved URL ────────────────────────────────────────────────────────────
static Future<String?> getSaved() async { static Future<String?> getSaved() async {
final prefs = await SharedPreferences.getInstance(); try {
return prefs.getString(_prefsKey); final prefs = await SharedPreferences.getInstance();
return prefs.getString(_prefsKey);
} catch (_) {
return null;
}
} }
static Future<void> save(String url) async { static Future<void> save(String url) async {
final prefs = await SharedPreferences.getInstance(); try {
await prefs.setString(_prefsKey, url); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, url);
} catch (_) {}
} }
static Future<void> clear() async { static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance(); try {
await prefs.remove(_prefsKey); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKey);
} catch (_) {}
} }
// ─── Ping (check if a saved URL is still reachable) ───────────────────── // ─── Ping (check if a server URL is reachable and authentic) ──────────────
static Future<bool> ping(String baseUrl) async { static Future<bool> ping(String baseUrl, {Duration timeout = _pingTimeout}) async {
try { try {
final clean = baseUrl.replaceAll(RegExp(r'/+$'), ''); final clean = baseUrl.replaceAll(RegExp(r'/+$'), '');
final dio = Dio( final dio = ApiConstants.createDio(
BaseOptions( BaseOptions(
connectTimeout: _pingTimeout, connectTimeout: timeout,
receiveTimeout: _pingTimeout, receiveTimeout: timeout,
sendTimeout: _pingTimeout, sendTimeout: timeout,
), ),
); );
final response = await dio.get(clean); final response = await dio.get(clean);
if (response.headers.value('x-tis-rms') == 'true' || if (response.headers.value('x-tis-rms') == 'true' ||
(response.statusCode == 200 && (response.statusCode == 200 &&
@@ -50,24 +59,29 @@ class ServerDiscoveryService {
response.data['message']?.toString().contains('TIS RMS') == true)) { response.data['message']?.toString().contains('TIS RMS') == true)) {
return true; return true;
} }
final rootUrl = clean.replaceAll(RegExp(r'/api$'), '');
if (rootUrl.isNotEmpty && rootUrl != clean) { // If URL ends in /api, probe root as fallback; if it doesn't end in /api, probe /api
final rootResponse = await dio.get(rootUrl); final alternateUrl = clean.endsWith('/api')
return rootResponse.headers.value('x-tis-rms') == 'true' || ? clean.replaceAll(RegExp(r'/api$'), '')
(rootResponse.statusCode == 200 && : '$clean/api';
rootResponse.data is Map &&
rootResponse.data['message']?.toString().contains('TIS RMS') == true); if (alternateUrl.isNotEmpty && alternateUrl != clean) {
final altResponse = await dio.get(alternateUrl);
return altResponse.headers.value('x-tis-rms') == 'true' ||
(altResponse.statusCode == 200 &&
altResponse.data is Map &&
altResponse.data['message']?.toString().contains('TIS RMS') == true);
} }
return response.statusCode == 200; return false;
} catch (_) { } catch (_) {
return false; return false;
} }
} }
// ─── Get device's own LAN IP addresses ─────────────────────────────────── // ─── Filtered physical LAN subnet prefixes ────────────────────────────────
/// Returns a list of subnet prefixes like ["192.168.1.", "10.0.0."] /// Returns valid physical IPv4 /24 subnet prefixes (e.g. ["192.168.1."]).
/// derived from the device's active network interfaces (non-loopback IPv4). /// Explicitly filters out virtual adapters (VirtualBox, VMware, WSL, vEthernet, APIPA, Bluetooth).
static Future<List<String>> getSubnetPrefixes() async { static Future<List<String>> getSubnetPrefixes() async {
final prefixes = <String>{}; final prefixes = <String>{};
try { try {
@@ -75,25 +89,49 @@ class ServerDiscoveryService {
type: InternetAddressType.IPv4, type: InternetAddressType.IPv4,
includeLoopback: false, includeLoopback: false,
); );
for (final iface in interfaces) { for (final iface in interfaces) {
final name = iface.name.toLowerCase();
// Ignore known virtual/tunnel/container interfaces that cause dead scanning on Windows
if (name.contains('virtualbox') ||
name.contains('vbox') ||
name.contains('vmware') ||
name.contains('wsl') ||
name.contains('bluetooth') ||
name.contains('vethernet') ||
name.contains('teredo') ||
name.contains('pseudo') ||
name.contains('tap') ||
name.contains('tun')) {
continue;
}
for (final addr in iface.addresses) { for (final addr in iface.addresses) {
final parts = addr.address.split('.'); final ip = addr.address;
if (ip.startsWith('127.') || // Loopback
ip.startsWith('169.254.') || // APIPA (unconfigured network)
ip.startsWith('192.168.56.') || // VirtualBox default host-only
ip == '0.0.0.0') {
continue;
}
final parts = ip.split('.');
if (parts.length == 4) { if (parts.length == 4) {
// Build the /24 subnet prefix (first 3 octets)
prefixes.add('${parts[0]}.${parts[1]}.${parts[2]}.'); prefixes.add('${parts[0]}.${parts[1]}.${parts[2]}.');
} }
} }
} }
} catch (_) { } catch (_) {
// If we cannot enumerate interfaces, fall back to nothing // Return empty on error
} }
return prefixes.toList(); return prefixes.toList();
} }
// ─── Scan one subnet prefix ─────────────────────────────────────────────── // ─── Concurrency-limited LAN Subnet Scanner ────────────────────────────────
/// Probes all 254 hosts in a given subnet prefix concurrently. /// Probes hosts in a subnet using a concurrency-bounded pool (chunks of 25)
/// Returns the first URL that responds with X-TIS-RMS: true, or null. /// and lightweight TCP socket checks first before attempting HTTP requests.
/// Prevents Windows 10 socket buffer exhaustion (WSAENOBUFS 10055).
static Future<String?> _scanSubnet( static Future<String?> _scanSubnet(
String prefix, { String prefix, {
void Function(int scanned, int total)? onProgress, void Function(int scanned, int total)? onProgress,
@@ -102,37 +140,54 @@ class ServerDiscoveryService {
int scanned = 0; int scanned = 0;
String? found; String? found;
final futures = List.generate(total, (i) async { const chunkSize = 25;
if (found != null) return; final allIps = List.generate(total, (i) => '$prefix${i + 1}');
final url = 'http://$prefix${i + 1}:$_port';
try { for (int chunkStart = 0; chunkStart < allIps.length; chunkStart += chunkSize) {
final dio = Dio( if (found != null) break;
BaseOptions(
connectTimeout: _probeTimeout, final chunk = allIps.sublist(
receiveTimeout: _probeTimeout, chunkStart,
sendTimeout: _probeTimeout, (chunkStart + chunkSize > allIps.length) ? allIps.length : chunkStart + chunkSize,
), );
);
final response = await dio.get('$url/'); await Future.wait(
if (response.headers.value('x-tis-rms') == 'true') { chunk.map((ip) async {
found = url; if (found != null) return;
} try {
} catch (_) { // Step 1: Lightweight raw TCP socket connect (300ms timeout)
// Not reachable — ignore final socket = await Socket.connect(
} finally { ip,
scanned++; _port,
onProgress?.call(scanned, total); timeout: _socketTimeout,
} );
}); socket.destroy();
// Step 2: TCP connection succeeded! Confirm HTTP identity
final url = 'http://$ip:$_port';
final isAlive = await ping(
url,
timeout: const Duration(milliseconds: 700),
);
if (isAlive) {
found = url;
}
} catch (_) {
// Port closed or unreachable — ignore
} finally {
scanned++;
onProgress?.call(scanned, total);
}
}),
);
}
await Future.wait(futures);
return found; return found;
} }
// ─── Main discovery entry point ─────────────────────────────────────────── // ─── Main discovery entry point ───────────────────────────────────────────
/// Scans all detected subnets. Returns the discovered server base URL /// Scans physical subnets. Returns discovered server base URL or null.
/// (e.g. "http://192.168.1.50:18484") or null if nothing is found.
static Future<String?> discover({ static Future<String?> discover({
void Function(String subnet, int scanned, int total)? onProgress, void Function(String subnet, int scanned, int total)? onProgress,
}) async { }) async {
@@ -149,25 +204,41 @@ class ServerDiscoveryService {
return null; return null;
} }
// ─── Master Resolution: Always Check LAN First, Then Tunnel Fallback ──────── // ─── Master Resolution: LAN First, Then Seamless Tunnel Fallback ──────────
/// Always prioritizes the Local Area Network (LAN) first. /// Resolves the optimal server endpoint:
/// If the LAN connection is not found or not working, seamlessly falls back to the Tunnel connection. /// 1. Fast Localhost check (for single-machine setups).
/// 2. Fast check of previously working saved LAN URL.
/// 3. Fast physical LAN discovery (bounded ~2.5s scan).
/// 4. Previously saved Tunnel URL or Cloud Tunnel fallback (https://tis-rms.cc.cd/api).
/// 5. VPS & Localhost fallbacks.
static Future<String?> resolveServerWithFallback({ static Future<String?> resolveServerWithFallback({
void Function(String message)? onProgress, void Function(String message)? onProgress,
}) async { }) async {
// 1. Check if previously saved URL is a LAN URL and is still reachable // 1. Fast Localhost Check (< 400ms)
onProgress?.call('Checking local host (127.0.0.1)…');
final localhostAlive = await ping(
ApiConstants.localhostUrl,
timeout: const Duration(milliseconds: 350),
);
if (localhostAlive) {
await save(ApiConstants.localhostUrl);
ApiConstants.setBaseUrl(ApiConstants.localhostUrl);
return ApiConstants.localhostUrl;
}
// 2. Fast check of previously working saved server (instant reconnect)
final saved = await getSaved(); final saved = await getSaved();
if (saved != null && _isLanUrl(saved)) { if (saved != null && saved.isNotEmpty) {
onProgress?.call('Checking saved LAN connection…'); onProgress?.call('Checking saved server connection…');
final alive = await ping(saved); final alive = await ping(saved, timeout: const Duration(seconds: 1));
if (alive) { if (alive) {
ApiConstants.setBaseUrl(saved); ApiConstants.setBaseUrl(saved);
return saved; return saved;
} }
} }
// 2. Scan Local Network (LAN) for any active TIS RMS server // 3. Scan physical Local Network (LAN)
onProgress?.call('Scanning local network (LAN)…'); onProgress?.call('Scanning local network (LAN)…');
final prefixes = await getSubnetPrefixes(); final prefixes = await getSubnetPrefixes();
if (prefixes.isNotEmpty) { if (prefixes.isNotEmpty) {
@@ -183,39 +254,41 @@ class ServerDiscoveryService {
} }
} }
// 3. Fallback to Tunnel connection if LAN is not working // 4. Cloudflare Tunnel Domain Fallback (https://tis-rms.cc.cd/api)
onProgress?.call('Connecting to tunnel domain…'); onProgress?.call('Connecting to Cloud Tunnel…');
final tunnelAlive = await ping(ApiConstants.tunnelUrl); final tunnelAlive = await ping(
ApiConstants.tunnelUrl,
timeout: const Duration(seconds: 3),
);
if (tunnelAlive) { if (tunnelAlive) {
await save(ApiConstants.tunnelUrl); await save(ApiConstants.tunnelUrl);
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl); ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
return ApiConstants.tunnelUrl; return ApiConstants.tunnelUrl;
} }
// 4. Check if saved non-LAN URL is alive (e.g. custom VPS) // 6. Cloud VPS Fallback (http://198.252.101.35:18484/api)
if (saved != null && !_isLanUrl(saved)) { onProgress?.call('Connecting to VPS backup…');
final savedAlive = await ping(saved); final vpsAlive = await ping(
if (savedAlive) { ApiConstants.vpsUrl,
ApiConstants.setBaseUrl(saved); timeout: const Duration(seconds: 2),
return saved; );
} if (vpsAlive) {
} await save(ApiConstants.vpsUrl);
ApiConstants.setBaseUrl(ApiConstants.vpsUrl);
// 5. Final fallback to localhost if running directly on the host machine return ApiConstants.vpsUrl;
final localhostAlive = await ping(ApiConstants.localhostUrl);
if (localhostAlive) {
await save(ApiConstants.localhostUrl);
ApiConstants.setBaseUrl(ApiConstants.localhostUrl);
return ApiConstants.localhostUrl;
} }
// Fallback default: Tunnel URL (https://tis-rms.cc.cd/api)
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
return null; return null;
} }
/// Helper to check whether a URL points to a private local area network address /// Helper to check whether a URL points to a local or private address
static bool _isLanUrl(String url) { static bool _isLanUrl(String url) {
return url.contains('192.168.') || return url.contains('192.168.') ||
url.contains('10.') || url.contains('10.') ||
url.contains('127.0.0.1') ||
url.contains('localhost') ||
url.contains('172.16.') || url.contains('172.16.') ||
url.contains('172.17.') || url.contains('172.17.') ||
url.contains('172.18.') || url.contains('172.18.') ||
@@ -27,7 +27,18 @@ class AuthRepository {
}, },
); );
final token = response.data['token'] as String; if (response.data is! Map) {
throw Exception(
'Server at ${ApiConstants.baseUrl} returned an unexpected response format. Please verify the server URL.',
);
}
final data = response.data as Map;
final token = data['token'] as String?;
if (token == null || token.isEmpty) {
throw Exception('Server did not return an authentication token.');
}
// Write to FlutterSecureStorage — consistent with all other repositories // Write to FlutterSecureStorage — consistent with all other repositories
await _storage.write(key: _tokenKey, value: token); await _storage.write(key: _tokenKey, value: token);
await _storage.write( await _storage.write(
@@ -37,7 +48,11 @@ class AuthRepository {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token); await prefs.setString(_tokenKey, token);
final userData = response.data['user']; final userData = data['user'] as Map?;
if (userData == null) {
throw Exception('Server did not return user details.');
}
return UserModel( return UserModel(
id: userData['id'], id: userData['id'],
username: userData['username'], username: userData['username'],
@@ -46,32 +61,67 @@ class AuthRepository {
role: userData['role'], role: userData['role'],
); );
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage = ApiConstants.extractErrorMessage(
e.response?.data['message'] ?? 'Failed to connect to the server.'; e,
'Failed to connect to the server at ${ApiConstants.baseUrl}.',
);
throw Exception(errorMessage); throw Exception(errorMessage);
} catch (e) {
if (e is FormatException) {
throw Exception(
'Server at ${ApiConstants.baseUrl} returned non-JSON response. Please verify the server URL.',
);
}
throw Exception(ApiConstants.extractErrorMessage(e));
} }
} }
/// Auto-login: returns user if a valid Remember Me token is stored, otherwise null. /// Auto-login: returns user if a valid Remember Me token is stored, otherwise null.
Future<UserModel?> tryAutoLogin() async { Future<UserModel?> tryAutoLogin() async {
String? rememberMe = await _storage.read(key: _rememberMeKey); String? rememberMe;
try {
rememberMe = await _storage
.read(key: _rememberMeKey)
.timeout(const Duration(seconds: 2), onTimeout: () => null);
} catch (_) {}
if (rememberMe != 'true') { if (rememberMe != 'true') {
final prefs = await SharedPreferences.getInstance(); try {
if (prefs.getBool('rememberMe') == true) { final prefs = await SharedPreferences.getInstance();
rememberMe = 'true'; if (prefs.getBool('rememberMe') == true) {
} rememberMe = 'true';
}
} catch (_) {}
} }
if (rememberMe != 'true') return null; if (rememberMe != 'true') return null;
final token = await _storage.read(key: _tokenKey); String? token;
if (token == null) return null; try {
token = await _storage
.read(key: _tokenKey)
.timeout(const Duration(seconds: 2), onTimeout: () => null);
} catch (_) {}
if (token == null || token.isEmpty) {
try {
final prefs = await SharedPreferences.getInstance();
token = prefs.getString(_tokenKey);
} catch (_) {}
}
if (token == null || token.isEmpty) return null;
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token); await prefs.setString(_tokenKey, token);
final options = Options(headers: {'Authorization': 'Bearer $token'}); final options = Options(
final response = await _dio.get('/auth/profile', options: options); headers: {'Authorization': 'Bearer $token'},
sendTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
);
final response = await _dio
.get('/auth/profile', options: options)
.timeout(const Duration(seconds: 6));
return UserModel.fromJson(response.data); return UserModel.fromJson(response.data);
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 401 || e.response?.statusCode == 403) { if (e.response?.statusCode == 401 || e.response?.statusCode == 403) {
@@ -80,11 +130,24 @@ class AuthRepository {
} }
// For network errors (no internet on startup), return null without wiping token // For network errors (no internet on startup), return null without wiping token
return null; return null;
} catch (_) {
return null;
} }
} }
Future<String?> getToken() async { Future<String?> getToken() async {
return await _storage.read(key: _tokenKey); try {
final token = await _storage
.read(key: _tokenKey)
.timeout(const Duration(seconds: 2), onTimeout: () => null);
if (token != null && token.isNotEmpty) return token;
} catch (_) {}
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
} catch (_) {
return null;
}
} }
Future<void> logout() async { Future<void> logout() async {
@@ -107,7 +170,7 @@ class AuthRepository {
return UserModel.fromJson(response.data); return UserModel.fromJson(response.data);
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to fetch profile.'; ApiConstants.extractErrorMessage(e, 'Failed to fetch profile.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -136,7 +199,7 @@ class AuthRepository {
); );
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to update profile.'; ApiConstants.extractErrorMessage(e, 'Failed to update profile.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -159,7 +222,7 @@ class AuthRepository {
); );
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to change password.'; ApiConstants.extractErrorMessage(e, 'Failed to change password.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -181,7 +244,7 @@ class AuthRepository {
); );
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to submit request.'; ApiConstants.extractErrorMessage(e, 'Failed to submit request.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -213,7 +276,7 @@ class AuthRepository {
return Map<String, dynamic>.from(response.data); return Map<String, dynamic>.from(response.data);
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to lookup account.'; ApiConstants.extractErrorMessage(e, 'Failed to lookup account.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -229,7 +292,7 @@ class AuthRepository {
'Verification code sent to your email.'; 'Verification code sent to your email.';
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to send email verification code.'; ApiConstants.extractErrorMessage(e, 'Failed to send email verification code.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -255,7 +318,7 @@ class AuthRepository {
'Password reset successfully.'; 'Password reset successfully.';
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to reset password.'; ApiConstants.extractErrorMessage(e, 'Failed to reset password.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -272,7 +335,7 @@ class AuthRepository {
'Verification email sent. Please check your inbox.'; 'Verification email sent. Please check your inbox.';
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to request account deletion.'; ApiConstants.extractErrorMessage(e, 'Failed to request account deletion.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -289,7 +352,7 @@ class AuthRepository {
'Your account has been deactivated.'; 'Your account has been deactivated.';
} on DioException catch (e) { } on DioException catch (e) {
final errorMessage = final errorMessage =
e.response?.data['message'] ?? 'Failed to deactivate account.'; ApiConstants.extractErrorMessage(e, 'Failed to deactivate account.');
throw Exception(errorMessage); throw Exception(errorMessage);
} }
} }
@@ -314,7 +377,7 @@ class AuthRepository {
final response = await _dio.get('/auth/sessions', options: options); final response = await _dio.get('/auth/sessions', options: options);
return List<Map<String, dynamic>>.from(response.data as List); return List<Map<String, dynamic>>.from(response.data as List);
} on DioException catch (e) { } on DioException catch (e) {
throw Exception(e.response?.data['message'] ?? 'Failed to fetch sessions.'); throw Exception(ApiConstants.extractErrorMessage(e, 'Failed to fetch sessions.'));
} }
} }
@@ -324,7 +387,7 @@ class AuthRepository {
final options = await _getAuthOptions(); final options = await _getAuthOptions();
await _dio.delete('/auth/sessions/$sessionId', options: options); await _dio.delete('/auth/sessions/$sessionId', options: options);
} on DioException catch (e) { } on DioException catch (e) {
throw Exception(e.response?.data['message'] ?? 'Failed to revoke session.'); throw Exception(ApiConstants.extractErrorMessage(e, 'Failed to revoke session.'));
} }
} }
@@ -334,7 +397,7 @@ class AuthRepository {
final options = await _getAuthOptions(); final options = await _getAuthOptions();
await _dio.delete('/auth/sessions', options: options); await _dio.delete('/auth/sessions', options: options);
} on DioException catch (e) { } on DioException catch (e) {
throw Exception(e.response?.data['message'] ?? 'Failed to revoke sessions.'); throw Exception(ApiConstants.extractErrorMessage(e, 'Failed to revoke sessions.'));
} }
} }
@@ -364,7 +427,7 @@ class AuthRepository {
); );
return Map<String, dynamic>.from(response.data); return Map<String, dynamic>.from(response.data);
} on DioException catch (e) { } on DioException catch (e) {
throw Exception(e.response?.data['message'] ?? 'Failed to fetch login logs.'); throw Exception(ApiConstants.extractErrorMessage(e, 'Failed to fetch login logs.'));
} }
} }
} }
@@ -201,11 +201,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
), ),
); );
} else { } else {
final error = ref.read(authProvider).error.toString(); final rawError = ref.read(authProvider).error;
final cleanMessage = ApiConstants.extractErrorMessage(
rawError,
'Failed to log in. Please check your credentials and server connection.',
);
showErrorDialog( showErrorDialog(
context, context,
'Login Failed', 'Login Failed',
error.replaceAll('Exception: ', ''), cleanMessage,
); );
} }
} }
@@ -679,6 +683,45 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
isLoading: isLoading, isLoading: isLoading,
onPressed: _handleLogin, onPressed: _handleLogin,
), ),
const SizedBox(height: AppSizes.p12),
// Server endpoint indicator & switcher
Center(
child: InkWell(
onTap: _showServerConfigDialog,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.dns_outlined,
size: 13,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 6),
Flexible(
child: Text(
'Server: ${ApiConstants.baseUrl}',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Icon(
Icons.settings_outlined,
size: 12,
color: AppColors.primaryGreen,
),
],
),
),
),
),
], ],
), ),
); );
@@ -6,6 +6,7 @@ import 'package:window_manager/window_manager.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_colors.dart';
import '../../../core/network/api_constants.dart';
import '../../../core/network/server_discovery.dart'; import '../../../core/network/server_discovery.dart';
import '../login/login_screen.dart'; import '../login/login_screen.dart';
import '../../layouts/windows_sidebar_layout.dart'; import '../../layouts/windows_sidebar_layout.dart';
@@ -29,6 +30,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
Timer? _quoteTimer; Timer? _quoteTimer;
int _quoteIndex = 0; int _quoteIndex = 0;
String _connectionStatus = 'Connecting to server…';
static const List<String> _entertainingPhrases = [ static const List<String> _entertainingPhrases = [
'Sharpening digital pencils…', 'Sharpening digital pencils…',
@@ -104,11 +106,28 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
// ── Server resolution logic ──────────────────────────────────────────────── // ── Server resolution logic ────────────────────────────────────────────────
Future<bool> _resolveServer({bool isRetry = false}) async { Future<bool> _resolveServer({bool isRetry = false}) async {
final found = await ServerDiscoveryService.resolveServerWithFallback(); try {
if (found != null) { final found = await ServerDiscoveryService.resolveServerWithFallback(
await Future.delayed(const Duration(milliseconds: 300)); onProgress: (status) {
return true; if (mounted) setState(() => _connectionStatus = status);
} },
).timeout(
const Duration(seconds: 10),
onTimeout: () => null,
);
if (found != null) {
if (mounted) {
setState(() {
_connectionStatus = found.contains('tis-rms')
? 'Connected to Cloud Tunnel'
: 'Connected to Local Server ($found)';
});
}
await Future.delayed(const Duration(milliseconds: 300));
return true;
}
} catch (_) {}
// If all connection attempts fail, show the dialog // If all connection attempts fail, show the dialog
if (!mounted) return false; if (!mounted) return false;
@@ -116,6 +135,54 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
return false; return false;
} }
Future<void> _showManualConfigDialog() async {
final controller = TextEditingController(text: ApiConstants.baseUrl);
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text('Configure Server URL'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter the local IP address or host name of your TIS RMS server:',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
const SizedBox(height: 12),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'http://192.168.1.100:18484/api',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.primaryGreen),
onPressed: () async {
final newUrl = controller.text.trim();
if (newUrl.isNotEmpty) {
ApiConstants.setBaseUrl(newUrl);
await ServerDiscoveryService.save(ApiConstants.baseUrl);
}
if (ctx.mounted) Navigator.pop(ctx);
_initializeApp(isRetry: true);
},
child: const Text('Connect'),
),
],
),
);
}
Future<void> _showConnectionFailedDialog({bool isRetry = false}) async { Future<void> _showConnectionFailedDialog({bool isRetry = false}) async {
if (!mounted) return; if (!mounted) return;
@@ -176,6 +243,34 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
], ],
), ),
actions: [ actions: [
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
await ServerDiscoveryService.save(ApiConstants.tunnelUrl);
_initializeApp(isRetry: true);
},
child: const Text(
'USE TUNNEL',
style: TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
_showManualConfigDialog();
},
child: const Text(
'MANUAL IP',
style: TextStyle(
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
@@ -335,6 +430,46 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 14),
// Real-time network connection status badge
AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primaryGreen.withValues(alpha: 0.25),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator(
strokeWidth: 1.8,
color: AppColors.primaryGreen,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
_connectionStatus,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: AppColors.primaryGreen,
letterSpacing: 0.2,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
], ],
), ),
), ),
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.32+32 version: 1.2.1+1
environment: environment:
sdk: ^3.11.5 sdk: ^3.11.5
+53
View File
@@ -0,0 +1,53 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/core/network/api_constants.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
HttpOverrides.global = null;
SharedPreferences.setMockInitialValues({});
});
test('ApiConstants.extractErrorMessage cleans FormatException and HTML', () {
try {
const badBody = '<!DOCTYPE html><html><body>Error</body></html>';
jsonDecode(badBody);
} catch (e) {
final message = ApiConstants.extractErrorMessage(e);
expect(message.contains('FormatUnexpected'), isFalse);
expect(message.contains('Unexpected character'), isFalse);
expect(message, contains('invalid response'));
}
final dioHtmlError = DioException(
requestOptions: RequestOptions(path: '/auth/login'),
response: Response(
requestOptions: RequestOptions(path: '/auth/login'),
statusCode: 400,
data: '<!DOCTYPE html><html><pre>SyntaxError</pre></html>',
),
);
expect(
ApiConstants.extractErrorMessage(dioHtmlError),
'Invalid request. Please check credentials or server URL.',
);
final dio401Error = DioException(
requestOptions: RequestOptions(path: '/auth/login'),
response: Response(
requestOptions: RequestOptions(path: '/auth/login'),
statusCode: 401,
data: {'message': 'Invalid username or password'},
),
);
expect(
ApiConstants.extractErrorMessage(dio401Error),
'Invalid username or password',
);
});
}
+6 -9
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
// Use a relative import to reliably find your main.dart file import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:frontend/main.dart'; import 'package:frontend/main.dart';
void main() { void main() {
@@ -9,16 +9,13 @@ void main() {
WidgetTester tester, WidgetTester tester,
) async { ) async {
// 1. Build our app and trigger a frame. // 1. Build our app and trigger a frame.
await tester.pumpWidget(const TisRmsApp()); await tester.pumpWidget(const ProviderScope(child: TisRmsApp()));
// 2. Verify that our branding text is present on the Splash Screen // 2. Verify that our branding text is present on the Splash Screen
expect(find.textContaining('TIS RMS'), findsWidgets); expect(find.textContaining('Talisay Integrated School'), findsWidgets);
// 3. Verify that the loading spinner is present // Clean up active timers/animations in test harness
expect(find.byType(CircularProgressIndicator), findsOneWidget); await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 2));
// Note: We don't pump the timer here because the test environment
// shouldn't wait for the 2.5 second simulated delay. We just want
// to ensure the initial widget tree renders without crashing.
}); });
} }