Files
TIS_RMS/frontend/lib/ui/screens/splash/splash_screen.dart
T

376 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
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';
import '../../layouts/android_bottom_nav_layout.dart';
import '../../providers/auth_provider.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen>
with SingleTickerProviderStateMixin {
String _statusText = 'Starting up…';
double? _scanProgress; // null = indeterminate, 0.0–1.0 = progress
late AnimationController _logoAnimController;
late Animation<double> _logoScaleAnimation;
@override
void initState() {
super.initState();
_logoAnimController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
_logoScaleAnimation = Tween<double>(begin: 0.95, end: 1.05).animate(
CurvedAnimation(parent: _logoAnimController, curve: Curves.easeInOut),
);
_initializeApp();
}
@override
void dispose() {
_logoAnimController.dispose();
super.dispose();
}
Future<void> _initializeApp() async {
await Future.delayed(const Duration(milliseconds: 800));
if (!mounted) return;
// ── Step 1: Resolve server URL ─────────────────────────────────────────
await _resolveServer();
if (!mounted) return;
// ── Step 2: Try auto-login ─────────────────────────────────────────────
setState(() {
_statusText = 'Checking session…';
_scanProgress = null;
});
final user = await ref.read(authProvider.notifier).tryAutoLogin();
if (!mounted) return;
if (user != null) {
final isDesktop = MediaQuery.of(context).size.width >= 800;
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => isDesktop
? WindowsSidebarLayout(userRole: user.role)
: AndroidBottomNavLayout(userRole: user.role),
),
);
} else {
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen()));
}
}
// ── Server resolution logic ────────────────────────────────────────────────
Future<void> _resolveServer() async {
// 1. Check saved URL first (all platforms including Windows)
final saved = await ServerDiscoveryService.getSaved();
if (saved != null) {
setState(() => _statusText = 'Connecting to saved server…');
final alive = await ServerDiscoveryService.ping(saved);
if (alive) {
ApiConstants.setBaseUrl(saved);
return;
}
// Saved server is unreachable — clear it and continue discovery
await ServerDiscoveryService.clear();
}
// 2. Check default configured internet/remote server in ApiConstants
final defaultRoot =
ApiConstants.baseUrl.replaceAll(RegExp(r'/api/?$'), '');
setState(() => _statusText = 'Connecting to remote server…');
final defaultAlive = await ServerDiscoveryService.ping(defaultRoot);
if (defaultAlive) {
await ServerDiscoveryService.save(defaultRoot);
ApiConstants.setBaseUrl(defaultRoot);
return;
}
// 3. On desktop platforms, check localhost as a fallback (if running local backend)
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
const localUrl = 'http://127.0.0.1:${ApiConstants.port}';
setState(() => _statusText = 'Checking local server…');
final localAlive = await ServerDiscoveryService.ping(localUrl);
if (localAlive) {
await ServerDiscoveryService.save(localUrl);
ApiConstants.setBaseUrl(localUrl);
return;
}
}
// 4. Run subnet scan (or prompt for manual entry if nothing found)
await _runScan();
}
Future<void> _runScan() async {
// Get own IP / subnet prefixes first
setState(() {
_statusText = 'Detecting network…';
_scanProgress = null;
});
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.',
);
return;
}
final subnetLabel = prefixes.map((p) => '${p}0/24').join(', ');
setState(() {
_statusText = 'Scanning $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;
});
},
);
if (found != null) {
await ServerDiscoveryService.save(found);
ApiConstants.setBaseUrl(found);
if (mounted) {
setState(() {
_statusText = 'Server found: $found';
_scanProgress = 1.0;
});
}
await Future.delayed(const Duration(milliseconds: 600));
} else {
await _showManualEntryDialog(
reason: 'No TIS RMS server was found on your network.',
);
}
}
// ── Manual entry dialog ────────────────────────────────────────────────────
Future<void> _showManualEntryDialog({required String reason}) async {
final controller = TextEditingController();
if (!mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
icon: const Icon(
Icons.wifi_find,
color: AppColors.primaryGreen,
size: 36,
),
title: const Text(
'Server Not Found',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
reason,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
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),
),
),
),
],
),
actions: [
TextButton(
onPressed: () async {
Navigator.of(ctx).pop();
// Re-run scan
await _runScan();
},
child: const Text(
'Retry Scan',
style: TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
),
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();
},
child: const Text('Connect'),
),
],
),
);
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF9F9F9),
body: Column(
children: [
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS))
const SizedBox(
height: 32,
child: WindowCaption(
brightness: Brightness.dark,
backgroundColor: AppColors.primaryGreen,
title: Text(
'TIS RMS',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (kIsWeb ||
Platform.isWindows ||
Platform.isMacOS ||
Platform.isLinux)
ScaleTransition(
scale: _logoScaleAnimation,
child: Image.asset(
'assets/images/logo.png',
width: 150,
height: 150,
),
)
else
Image.asset(
'assets/images/logo.png',
width: 150,
height: 150,
),
const SizedBox(height: 24),
const Text(
'TIS RMS',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF1C8248),
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
const Text(
'Record Management System',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 48),
// Progress indicator — linear when scanning, circular otherwise
SizedBox(
width: 240,
child: _scanProgress != null
? Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _scanProgress,
minHeight: 6,
backgroundColor: Colors.grey.shade200,
valueColor:
const AlwaysStoppedAnimation<Color>(
Color(0xFF1C8248),
),
),
),
],
)
: const Center(
child: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF1C8248),
),
strokeWidth: 3,
),
),
),
),
const SizedBox(height: 16),
Text(
_statusText,
style: const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
),
],
),
);
}
}