Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5abd5a812c |
No files matched your search
@@ -10,7 +10,7 @@
|
||||
#define OutputBaseFilename "TIS_RMS_Client_Setup_v" + MyAppVersion
|
||||
#endif
|
||||
#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 MyAppIcon "windows\runner\resources\app_icon.ico"
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show Platform, HttpClient;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -32,7 +33,7 @@ class ApiConstants {
|
||||
if (kIsWeb) return 'Web Browser';
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
final hostname = Platform.localHostname;
|
||||
final hostname = Platform.localHostname.replaceAll(RegExp(r'[^\x20-\x7E]'), '').trim();
|
||||
return hostname.isNotEmpty ? hostname : 'Windows PC';
|
||||
}
|
||||
if (Platform.isAndroid) return 'Android Device';
|
||||
@@ -43,6 +44,57 @@ class ApiConstants {
|
||||
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}) {
|
||||
// Strip trailing slash then append /api
|
||||
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]) {
|
||||
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(
|
||||
InterceptorsWrapper(
|
||||
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-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
|
||||
final authHeader = options.headers['Authorization']?.toString() ?? '';
|
||||
@@ -94,12 +192,16 @@ class ApiConstants {
|
||||
|
||||
if (statusCode == 401 || (statusCode == 403 && isDeactivated)) {
|
||||
// Expire / wipe stored JWT token immediately on deactivation or invalidation
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.delete(key: 'jwt_token');
|
||||
await storage.delete(key: 'remember_me');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('jwt_token');
|
||||
await prefs.remove('rememberMe');
|
||||
try {
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.delete(key: 'jwt_token');
|
||||
await storage.delete(key: 'remember_me');
|
||||
} catch (_) {}
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('jwt_token');
|
||||
await prefs.remove('rememberMe');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
|
||||
@@ -3,46 +3,55 @@ import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'api_constants.dart';
|
||||
|
||||
/// Discovers the TIS RMS server on the local network by:
|
||||
/// 1. Reading the device's own LAN IP to derive the subnet prefix.
|
||||
/// 2. Concurrently probing all 254 host addresses on that subnet.
|
||||
/// 3. Verifying each candidate by checking the X-TIS-RMS response header.
|
||||
/// 4. If LAN connection is not found/working, automatically falls back to the Tunnel connection.
|
||||
/// Discovers the TIS RMS server on the local network (LAN) and manages seamless fallback to Cloud Tunnel.
|
||||
/// 1. Verifies physical network interfaces, filtering out virtual/bridge/loopback adapters.
|
||||
/// 2. Probes local subnet hosts with bounded concurrency (pools of 25) using lightweight raw TCP checks first.
|
||||
/// 3. Confirms identity via HTTP X-TIS-RMS response header.
|
||||
/// 4. Prioritizes LAN for speed and offline resilience, seamlessly falling back to Cloud Tunnel (https://tis-rms.cc.cd/api).
|
||||
class ServerDiscoveryService {
|
||||
static const int _port = 18484;
|
||||
static const String _prefsKey = 'server_url';
|
||||
static const Duration _probeTimeout = Duration(milliseconds: 800);
|
||||
static const Duration _pingTimeout = Duration(seconds: 3);
|
||||
static const Duration _socketTimeout = Duration(milliseconds: 300);
|
||||
static const Duration _pingTimeout = Duration(seconds: 2);
|
||||
|
||||
// ─── Saved URL ────────────────────────────────────────────────────────────
|
||||
|
||||
static Future<String?> getSaved() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_prefsKey);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_prefsKey);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> save(String url) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKey, url);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKey, url);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKey);
|
||||
try {
|
||||
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 {
|
||||
final clean = baseUrl.replaceAll(RegExp(r'/+$'), '');
|
||||
final dio = Dio(
|
||||
final dio = ApiConstants.createDio(
|
||||
BaseOptions(
|
||||
connectTimeout: _pingTimeout,
|
||||
receiveTimeout: _pingTimeout,
|
||||
sendTimeout: _pingTimeout,
|
||||
connectTimeout: timeout,
|
||||
receiveTimeout: timeout,
|
||||
sendTimeout: timeout,
|
||||
),
|
||||
);
|
||||
|
||||
final response = await dio.get(clean);
|
||||
if (response.headers.value('x-tis-rms') == 'true' ||
|
||||
(response.statusCode == 200 &&
|
||||
@@ -50,24 +59,29 @@ class ServerDiscoveryService {
|
||||
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);
|
||||
|
||||
// If URL ends in /api, probe root as fallback; if it doesn't end in /api, probe /api
|
||||
final alternateUrl = clean.endsWith('/api')
|
||||
? clean.replaceAll(RegExp(r'/api$'), '')
|
||||
: '$clean/api';
|
||||
|
||||
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 (_) {
|
||||
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."]
|
||||
/// derived from the device's active network interfaces (non-loopback IPv4).
|
||||
/// Returns valid physical IPv4 /24 subnet prefixes (e.g. ["192.168.1."]).
|
||||
/// Explicitly filters out virtual adapters (VirtualBox, VMware, WSL, vEthernet, APIPA, Bluetooth).
|
||||
static Future<List<String>> getSubnetPrefixes() async {
|
||||
final prefixes = <String>{};
|
||||
try {
|
||||
@@ -75,25 +89,49 @@ class ServerDiscoveryService {
|
||||
type: InternetAddressType.IPv4,
|
||||
includeLoopback: false,
|
||||
);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
// Build the /24 subnet prefix (first 3 octets)
|
||||
prefixes.add('${parts[0]}.${parts[1]}.${parts[2]}.');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// If we cannot enumerate interfaces, fall back to nothing
|
||||
// Return empty on error
|
||||
}
|
||||
return prefixes.toList();
|
||||
}
|
||||
|
||||
// ─── Scan one subnet prefix ───────────────────────────────────────────────
|
||||
// ─── Concurrency-limited LAN Subnet Scanner ────────────────────────────────
|
||||
|
||||
/// Probes all 254 hosts in a given subnet prefix concurrently.
|
||||
/// Returns the first URL that responds with X-TIS-RMS: true, or null.
|
||||
/// Probes hosts in a subnet using a concurrency-bounded pool (chunks of 25)
|
||||
/// and lightweight TCP socket checks first before attempting HTTP requests.
|
||||
/// Prevents Windows 10 socket buffer exhaustion (WSAENOBUFS 10055).
|
||||
static Future<String?> _scanSubnet(
|
||||
String prefix, {
|
||||
void Function(int scanned, int total)? onProgress,
|
||||
@@ -102,37 +140,54 @@ class ServerDiscoveryService {
|
||||
int scanned = 0;
|
||||
String? found;
|
||||
|
||||
final futures = List.generate(total, (i) async {
|
||||
if (found != null) return;
|
||||
final url = 'http://$prefix${i + 1}:$_port';
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _probeTimeout,
|
||||
receiveTimeout: _probeTimeout,
|
||||
sendTimeout: _probeTimeout,
|
||||
),
|
||||
);
|
||||
final response = await dio.get('$url/');
|
||||
if (response.headers.value('x-tis-rms') == 'true') {
|
||||
found = url;
|
||||
}
|
||||
} catch (_) {
|
||||
// Not reachable — ignore
|
||||
} finally {
|
||||
scanned++;
|
||||
onProgress?.call(scanned, total);
|
||||
}
|
||||
});
|
||||
const chunkSize = 25;
|
||||
final allIps = List.generate(total, (i) => '$prefix${i + 1}');
|
||||
|
||||
for (int chunkStart = 0; chunkStart < allIps.length; chunkStart += chunkSize) {
|
||||
if (found != null) break;
|
||||
|
||||
final chunk = allIps.sublist(
|
||||
chunkStart,
|
||||
(chunkStart + chunkSize > allIps.length) ? allIps.length : chunkStart + chunkSize,
|
||||
);
|
||||
|
||||
await Future.wait(
|
||||
chunk.map((ip) async {
|
||||
if (found != null) return;
|
||||
try {
|
||||
// Step 1: Lightweight raw TCP socket connect (300ms timeout)
|
||||
final socket = await Socket.connect(
|
||||
ip,
|
||||
_port,
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Main discovery entry point ───────────────────────────────────────────
|
||||
|
||||
/// Scans all detected subnets. Returns the discovered server base URL
|
||||
/// (e.g. "http://192.168.1.50:18484") or null if nothing is found.
|
||||
/// Scans physical subnets. Returns discovered server base URL or null.
|
||||
static Future<String?> discover({
|
||||
void Function(String subnet, int scanned, int total)? onProgress,
|
||||
}) async {
|
||||
@@ -149,25 +204,41 @@ class ServerDiscoveryService {
|
||||
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.
|
||||
/// If the LAN connection is not found or not working, seamlessly falls back to the Tunnel connection.
|
||||
/// Resolves the optimal server endpoint:
|
||||
/// 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({
|
||||
void Function(String message)? onProgress,
|
||||
}) 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();
|
||||
if (saved != null && _isLanUrl(saved)) {
|
||||
onProgress?.call('Checking saved LAN connection…');
|
||||
final alive = await ping(saved);
|
||||
if (saved != null && saved.isNotEmpty) {
|
||||
onProgress?.call('Checking saved server connection…');
|
||||
final alive = await ping(saved, timeout: const Duration(seconds: 1));
|
||||
if (alive) {
|
||||
ApiConstants.setBaseUrl(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)…');
|
||||
final prefixes = await getSubnetPrefixes();
|
||||
if (prefixes.isNotEmpty) {
|
||||
@@ -183,39 +254,41 @@ class ServerDiscoveryService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to Tunnel connection if LAN is not working
|
||||
onProgress?.call('Connecting to tunnel domain…');
|
||||
final tunnelAlive = await ping(ApiConstants.tunnelUrl);
|
||||
// 4. Cloudflare Tunnel Domain Fallback (https://tis-rms.cc.cd/api)
|
||||
onProgress?.call('Connecting to Cloud Tunnel…');
|
||||
final tunnelAlive = await ping(
|
||||
ApiConstants.tunnelUrl,
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
if (tunnelAlive) {
|
||||
await save(ApiConstants.tunnelUrl);
|
||||
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
|
||||
return ApiConstants.tunnelUrl;
|
||||
}
|
||||
|
||||
// 4. Check if saved non-LAN URL is alive (e.g. custom VPS)
|
||||
if (saved != null && !_isLanUrl(saved)) {
|
||||
final savedAlive = await ping(saved);
|
||||
if (savedAlive) {
|
||||
ApiConstants.setBaseUrl(saved);
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Final fallback to localhost if running directly on the host machine
|
||||
final localhostAlive = await ping(ApiConstants.localhostUrl);
|
||||
if (localhostAlive) {
|
||||
await save(ApiConstants.localhostUrl);
|
||||
ApiConstants.setBaseUrl(ApiConstants.localhostUrl);
|
||||
return ApiConstants.localhostUrl;
|
||||
// 6. Cloud VPS Fallback (http://198.252.101.35:18484/api)
|
||||
onProgress?.call('Connecting to VPS backup…');
|
||||
final vpsAlive = await ping(
|
||||
ApiConstants.vpsUrl,
|
||||
timeout: const Duration(seconds: 2),
|
||||
);
|
||||
if (vpsAlive) {
|
||||
await save(ApiConstants.vpsUrl);
|
||||
ApiConstants.setBaseUrl(ApiConstants.vpsUrl);
|
||||
return ApiConstants.vpsUrl;
|
||||
}
|
||||
|
||||
// Fallback default: Tunnel URL (https://tis-rms.cc.cd/api)
|
||||
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
|
||||
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) {
|
||||
return url.contains('192.168.') ||
|
||||
url.contains('10.') ||
|
||||
url.contains('127.0.0.1') ||
|
||||
url.contains('localhost') ||
|
||||
url.contains('172.16.') ||
|
||||
url.contains('172.17.') ||
|
||||
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
|
||||
await _storage.write(key: _tokenKey, value: token);
|
||||
await _storage.write(
|
||||
@@ -37,7 +48,11 @@ class AuthRepository {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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(
|
||||
id: userData['id'],
|
||||
username: userData['username'],
|
||||
@@ -46,32 +61,67 @@ class AuthRepository {
|
||||
role: userData['role'],
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to connect to the server.';
|
||||
final errorMessage = ApiConstants.extractErrorMessage(
|
||||
e,
|
||||
'Failed to connect to the server at ${ApiConstants.baseUrl}.',
|
||||
);
|
||||
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.
|
||||
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') {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool('rememberMe') == true) {
|
||||
rememberMe = 'true';
|
||||
}
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool('rememberMe') == true) {
|
||||
rememberMe = 'true';
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (rememberMe != 'true') return null;
|
||||
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
if (token == null) return null;
|
||||
String? token;
|
||||
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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, token);
|
||||
|
||||
final options = Options(headers: {'Authorization': 'Bearer $token'});
|
||||
final response = await _dio.get('/auth/profile', options: options);
|
||||
final 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);
|
||||
} on DioException catch (e) {
|
||||
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
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -107,7 +170,7 @@ class AuthRepository {
|
||||
return UserModel.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to fetch profile.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to fetch profile.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +199,7 @@ class AuthRepository {
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to update profile.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to update profile.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -159,7 +222,7 @@ class AuthRepository {
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to change password.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to change password.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -181,7 +244,7 @@ class AuthRepository {
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to submit request.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to submit request.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +276,7 @@ class AuthRepository {
|
||||
return Map<String, dynamic>.from(response.data);
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to lookup account.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to lookup account.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -229,7 +292,7 @@ class AuthRepository {
|
||||
'Verification code sent to your email.';
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to send email verification code.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to send email verification code.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +318,7 @@ class AuthRepository {
|
||||
'Password reset successfully.';
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to reset password.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to reset password.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +335,7 @@ class AuthRepository {
|
||||
'Verification email sent. Please check your inbox.';
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to request account deletion.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to request account deletion.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -289,7 +352,7 @@ class AuthRepository {
|
||||
'Your account has been deactivated.';
|
||||
} on DioException catch (e) {
|
||||
final errorMessage =
|
||||
e.response?.data['message'] ?? 'Failed to deactivate account.';
|
||||
ApiConstants.extractErrorMessage(e, 'Failed to deactivate account.');
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -314,7 +377,7 @@ class AuthRepository {
|
||||
final response = await _dio.get('/auth/sessions', options: options);
|
||||
return List<Map<String, dynamic>>.from(response.data as List);
|
||||
} 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();
|
||||
await _dio.delete('/auth/sessions/$sessionId', options: options);
|
||||
} 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();
|
||||
await _dio.delete('/auth/sessions', options: options);
|
||||
} 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);
|
||||
} 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 {
|
||||
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(
|
||||
context,
|
||||
'Login Failed',
|
||||
error.replaceAll('Exception: ', ''),
|
||||
cleanMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -679,6 +683,45 @@ class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProv
|
||||
isLoading: isLoading,
|
||||
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_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/network/api_constants.dart';
|
||||
import '../../../core/network/server_discovery.dart';
|
||||
import '../login/login_screen.dart';
|
||||
import '../../layouts/windows_sidebar_layout.dart';
|
||||
@@ -29,6 +30,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
|
||||
Timer? _quoteTimer;
|
||||
int _quoteIndex = 0;
|
||||
String _connectionStatus = 'Connecting to server…';
|
||||
|
||||
static const List<String> _entertainingPhrases = [
|
||||
'Sharpening digital pencils…',
|
||||
@@ -104,11 +106,28 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
// ── Server resolution logic ────────────────────────────────────────────────
|
||||
|
||||
Future<bool> _resolveServer({bool isRetry = false}) async {
|
||||
final found = await ServerDiscoveryService.resolveServerWithFallback();
|
||||
if (found != null) {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
final found = await ServerDiscoveryService.resolveServerWithFallback(
|
||||
onProgress: (status) {
|
||||
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 (!mounted) return false;
|
||||
@@ -116,6 +135,54 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
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 {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -176,6 +243,34 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
],
|
||||
),
|
||||
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(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
@@ -335,6 +430,46 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
# 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.
|
||||
version: 1.0.32+32
|
||||
version: 1.2.1+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.5
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
void main() {
|
||||
@@ -9,16 +9,13 @@ void main() {
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// 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
|
||||
expect(find.textContaining('TIS RMS'), findsWidgets);
|
||||
expect(find.textContaining('Talisay Integrated School'), findsWidgets);
|
||||
|
||||
// 3. Verify that the loading spinner is present
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// 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.
|
||||
// Clean up active timers/animations in test harness
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user