Files
TIS_RMS/frontend/lib/ui/screens/login/login_screen.dart
T

975 lines
38 KiB
Dart

import 'dart:io' show Platform;
import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:window_manager/window_manager.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_sizes.dart';
import '../../shared/inputs/custom_text_field.dart';
import '../../shared/buttons/primary_button.dart';
import '../../layouts/windows_sidebar_layout.dart';
import '../../layouts/android_bottom_nav_layout.dart';
import '../../providers/auth_provider.dart';
import 'package:frontend/ui/providers/navigation_provider.dart';
import '../../shared/dialogs/error_dialog.dart';
import '../../shared/dialogs/success_dialog.dart';
import '../../shared/widgets/abstract_background.dart';
import '../../../core/utils/validators.dart';
import '../../../core/network/api_constants.dart';
import '../../../core/network/server_discovery.dart';
import '../../shared/inputs/password_strength_indicator.dart';
class LoginScreen extends ConsumerStatefulWidget {
final bool sessionExpired;
const LoginScreen({super.key, this.sessionExpired = false});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> with SingleTickerProviderStateMixin {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _rememberMe = false;
int _logoTapCount = 0;
final FocusNode _usernameFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
late AnimationController _animController;
late Animation<double> _revealAnimation;
void _handleLogoTap() {
_logoTapCount++;
if (_logoTapCount >= 3) {
_logoTapCount = 0;
_showServerConfigDialog();
}
}
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
value: 1.0,
duration: const Duration(milliseconds: 400),
);
_revealAnimation = CurvedAnimation(
parent: _animController,
curve: Curves.easeInOutCubic,
);
_usernameFocus.addListener(_onFocusChange);
_passwordFocus.addListener(_onFocusChange);
_usernameController.addListener(_onTextChange);
_passwordController.addListener(_onTextChange);
_loadRememberMe(); // Load saved credentials on startup
if (widget.sessionExpired) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Row(
children: [
Icon(Icons.info_outline, color: AppColors.primaryGreen),
SizedBox(width: 8),
Text('Session Expired'),
],
),
content: const Text(
'You have been logged out due to 5 minutes of inactivity.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
],
),
);
});
}
}
void _onTextChange() {
if (!mounted) return;
_updateAnimationState();
}
void _onFocusChange() {
if (!mounted) return;
_updateAnimationState();
}
void _updateAnimationState() {
final hasFocus = _usernameFocus.hasFocus || _passwordFocus.hasFocus;
final hasText = _usernameController.text.isNotEmpty || _passwordController.text.isNotEmpty;
if (hasFocus || hasText) {
_animController.reverse();
} else {
_animController.forward();
}
}
// --- Added: Load saved credentials for Remember Me ---
Future<void> _loadRememberMe() async {
final prefs = await SharedPreferences.getInstance();
final isRemembered = prefs.getBool('rememberMe') ?? false;
if (isRemembered) {
setState(() {
_rememberMe = true;
_usernameController.text = prefs.getString('saved_username') ?? '';
// Note: For a production app, use 'flutter_secure_storage' to save passwords securely.
_passwordController.text = prefs.getString('saved_password') ?? '';
});
}
}
@override
void dispose() {
_usernameFocus.removeListener(_onFocusChange);
_passwordFocus.removeListener(_onFocusChange);
_usernameController.removeListener(_onTextChange);
_passwordController.removeListener(_onTextChange);
_usernameFocus.dispose();
_passwordFocus.dispose();
_animController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _handleLogin() async {
if (_usernameController.text.trim().isEmpty ||
_passwordController.text.isEmpty) {
showErrorDialog(
context,
'Missing Credentials',
'Please enter both your username and password to continue.',
);
return;
}
FocusScope.of(context).unfocus();
final success = await ref
.read(authProvider.notifier)
.login(
_usernameController.text.trim(),
_passwordController.text,
rememberMe: _rememberMe,
);
if (!mounted) return;
if (success) {
// --- Added: Save or clear credentials based on checkbox ---
final prefs = await SharedPreferences.getInstance();
if (_rememberMe) {
await prefs.setBool('rememberMe', true);
await prefs.setString(
'saved_username',
_usernameController.text.trim(),
);
} else {
await prefs.remove('rememberMe');
await prefs.remove('saved_username');
await prefs.remove('saved_password');
}
if (!mounted) return;
final user = ref.read(authProvider).value;
final isDesktop = MediaQuery.of(context).size.width >= 800;
// Ensure we always redirect to Dashboard after a successful login
ref.read(activeTabProvider.notifier).setTab('Dashboard');
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => isDesktop
? WindowsSidebarLayout(userRole: user!.role)
: AndroidBottomNavLayout(userRole: user!.role),
),
);
} else {
final error = ref.read(authProvider).error.toString();
showErrorDialog(
context,
'Login Failed',
error.replaceAll('Exception: ', ''),
);
}
}
void _showForgotPasswordDialog() {
showDialog(
context: context,
builder: (ctx) => _ForgotPasswordDialog(
initialUsername: _usernameController.text,
onSuccess: (msg) {
Navigator.pop(ctx);
showSuccessDialog(
context,
title: 'Request Submitted',
message: msg,
///onDismissed: () {}
);
},
),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () {
FocusScope.of(context).unfocus();
},
child: Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Column(
children: [
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS))
SizedBox(
height: 32,
child: WindowCaption(
brightness: Brightness.dark,
backgroundColor: AppColors.primaryGreen,
title: const Text(
'TIS Record Management System',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
Expanded(
child: AbstractBackground(
child: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 800) {
// Desktop: Split Layout
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
return Container(
color: Colors.transparent,
child: Row(
children: [
if (_revealAnimation.value > 0)
Flexible(
flex: (5000 * _revealAnimation.value).toInt(),
child: Opacity(
opacity: _revealAnimation.value,
child: Container(
padding: const EdgeInsets.all(AppSizes.p48),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: _handleLogoTap,
child: Image.asset(
'assets/images/logo.png',
width: 220,
height: 220,
),
),
const SizedBox(height: AppSizes.p24),
Text(
'Talisay Integrated School',
style: TextStyle(
fontSize: 42,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : AppColors.textPrimary,
letterSpacing: 2.0,
),
textAlign: TextAlign.center,
),
Text(
'Tiaong, Quezon',
style: TextStyle(
fontSize: 18,
color: isDark ? Colors.white70 : AppColors.textSecondary,
letterSpacing: 1.0,
),
),
const SizedBox(height: AppSizes.p32),
Text(
'Record Management System',
style: TextStyle(
fontSize: 20,
color: isDark ? Colors.white : AppColors.primaryGreen,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSizes.p8),
Text(
'Secure Academic Records Database System',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
color: isDark ? Colors.white70 : AppColors.textSecondary,
),
),
],
),
),
),
),
),
Expanded(
flex: 4000,
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24.0 * _revealAnimation.value),
bottomLeft: Radius.circular(24.0 * _revealAnimation.value),
),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 18.0,
sigmaY: 18.0,
),
child: Container(
decoration: BoxDecoration(
color: isDark
? AppColors.darkSurfaceCard.withValues(alpha: 0.85)
: Colors.white.withValues(alpha: 0.86),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24.0 * _revealAnimation.value),
bottomLeft: Radius.circular(24.0 * _revealAnimation.value),
),
border: Border(
left: BorderSide(
color: (isDark ? Colors.white : AppColors.primaryGreen)
.withValues(alpha: 0.12 * _revealAnimation.value),
width: 1.5,
),
),
),
child: Align(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
padding: const EdgeInsets.only(top: 80, left: 24, right: 24, bottom: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: _buildLoginForm(),
),
),
),
),
),
),
),
],
),
);
}
);
} else {
// Mobile: Stacked Layout
return AnimatedBuilder(
animation: _revealAnimation,
builder: (context, child) {
return Container(
color: Colors.transparent,
child: Column(
children: [
if (_revealAnimation.value > 0)
ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: _revealAnimation.value,
child: Opacity(
opacity: _revealAnimation.value,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: AppSizes.p48,
horizontal: AppSizes.p24,
),
child: SafeArea(
bottom: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: _handleLogoTap,
child: Image.asset(
'assets/images/logo.png',
width: 100,
height: 100,
),
),
const SizedBox(height: AppSizes.p16),
Text(
'Talisay Integrated School',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : AppColors.textPrimary,
letterSpacing: 1.5,
),
textAlign: TextAlign.center,
),
Text(
'Tiaong, Quezon',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white70 : AppColors.textSecondary,
letterSpacing: 1.0,
),
),
const SizedBox(height: AppSizes.p16),
Text(
'Record Management System',
style: TextStyle(
fontSize: 16,
color: isDark ? Colors.white : AppColors.primaryGreen,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSizes.p4),
Text(
'Secure Academic Records Database System',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white70 : AppColors.textSecondary,
),
),
],
),
),
),
),
),
),
Expanded(
child: SafeArea(
top: false,
bottom: true,
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24.0 * _revealAnimation.value),
topRight: Radius.circular(24.0 * _revealAnimation.value),
),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 18.0,
sigmaY: 18.0,
),
child: Container(
decoration: BoxDecoration(
color: isDark
? AppColors.darkSurfaceCard.withValues(alpha: 0.85)
: Colors.white.withValues(alpha: 0.86),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24.0 * _revealAnimation.value),
topRight: Radius.circular(24.0 * _revealAnimation.value),
),
border: Border(
top: BorderSide(
color: (isDark ? Colors.white : AppColors.primaryGreen)
.withValues(alpha: 0.12 * _revealAnimation.value),
width: 1.5,
),
),
),
child: Align(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
padding: const EdgeInsets.only(top: 40, left: 24, right: 24, bottom: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: _buildLoginForm(),
),
),
),
),
),
),
),
),
],
),
);
}
);
}
},
),
),
),
],
),
));
}
Future<void> _showServerConfigDialog() async {
final controller = TextEditingController(text: ApiConstants.baseUrl);
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Text(
'Server Configuration',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
decoration: InputDecoration(
labelText: 'Server URL',
hintText: 'http://198.252.101.35:18484/api',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
],
),
actions: [
TextButton(
onPressed: () async {
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
await ServerDiscoveryService.save(ApiConstants.baseUrl);
if (mounted) setState(() {});
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('Use Tunnel'),
),
TextButton(
onPressed: () async {
ApiConstants.setBaseUrl(ApiConstants.vpsUrl);
await ServerDiscoveryService.save(ApiConstants.baseUrl);
if (mounted) setState(() {});
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('Use VPS'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
showDialog(
context: context,
barrierDismissible: false,
builder: (scanCtx) => const _NetworkScanDialog(),
);
},
child: const Text('Scan LAN'),
),
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 (mounted) setState(() {});
}
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('Save'),
),
],
),
);
}
Widget _buildLoginForm() {
final authState = ref.watch(authProvider);
final isLoading = authState.isLoading;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.enter): _handleLogin,
const SingleActivator(LogicalKeyboardKey.numpadEnter): _handleLogin,
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_revealAnimation.value < 0.5
? 'Login to TIS Record Management System'
: 'Login',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSizes.p32),
CustomTextField(
hintText: 'Username',
prefixIcon: Icons.person_outline,
controller: _usernameController,
focusNode: _usernameFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) => FocusScope.of(context).requestFocus(_passwordFocus),
),
const SizedBox(height: AppSizes.p16),
CustomTextField(
hintText: 'Password',
prefixIcon: Icons.lock_outline,
controller: _passwordController,
focusNode: _passwordFocus,
isPassword: true,
obscureText: _obscurePassword,
onToggleVisibility: () =>
setState(() => _obscurePassword = !_obscurePassword),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _handleLogin(),
),
const SizedBox(height: AppSizes.p8),
// Remember Me + Forgot Password row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Checkbox(
value: _rememberMe,
activeColor: AppColors.primaryGreen,
onChanged: (val) =>
setState(() => _rememberMe = val ?? false),
),
Text(
'Remember Me',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
TextButton(
onPressed: _showForgotPasswordDialog,
child: const Text(
'Forgot Password?',
style: TextStyle(
color: AppColors.primaryGreen,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: AppSizes.p16),
PrimaryButton(
label: 'LOGIN',
isLoading: isLoading,
onPressed: _handleLogin,
),
],
),
);
}
}
// ================================================================
// FORGOT PASSWORD DIALOG
// ================================================================
class _ForgotPasswordDialog extends ConsumerStatefulWidget {
final void Function(String message) onSuccess;
final String? initialUsername;
const _ForgotPasswordDialog({required this.onSuccess, this.initialUsername});
@override
ConsumerState<_ForgotPasswordDialog> createState() =>
_ForgotPasswordDialogState();
}
class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> {
final _formKey = GlobalKey<FormState>();
final _usernameCtrl = TextEditingController();
final _newPassCtrl = TextEditingController();
final _confirmPassCtrl = TextEditingController();
bool _obscurePasswords = true;
bool _isLoading = false;
@override
void initState() {
super.initState();
if (widget.initialUsername != null &&
widget.initialUsername!.trim().isNotEmpty) {
_usernameCtrl.text = widget.initialUsername!.trim();
}
}
@override
void dispose() {
_usernameCtrl.dispose();
_newPassCtrl.dispose();
_confirmPassCtrl.dispose();
super.dispose();
}
Future<void> _handleSubmit() async {
if (!_formKey.currentState!.validate()) return;
final username = _usernameCtrl.text.trim();
final newPass = _newPassCtrl.text;
final confirmPass = _confirmPassCtrl.text;
if (newPass != confirmPass) {
showErrorDialog(context, 'Password Mismatch', 'Passwords do not match.');
return;
}
setState(() {
_isLoading = true;
});
try {
final repo = ref.read(authRepositoryProvider);
await repo.requestPasswordReset(
username: username,
newPassword: newPass,
confirmPassword: confirmPass,
);
if (mounted) {
widget.onSuccess('Reset request submitted. Awaiting Admin approval.');
}
} catch (e) {
if (mounted) {
showErrorDialog(
context,
'Request Failed',
e.toString().replaceAll('Exception: ', ''),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 600;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
),
insetPadding: EdgeInsets.all(isMobile ? 16 : 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: EdgeInsets.all(isMobile ? 16 : 24),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.lock_reset,
color: AppColors.primaryGreen,
size: isMobile ? 24 : 28,
),
SizedBox(width: isMobile ? 8 : 12),
Expanded(
child: Text(
'Forgot Password',
style: TextStyle(
fontSize: isMobile ? 18 : 22,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: Icon(Icons.close, size: isMobile ? 20 : 24),
onPressed: () => Navigator.pop(context),
),
],
),
SizedBox(height: isMobile ? 4 : 8),
Text(
'Admin and Teacher accounts only. Your request will be reviewed by the Admin.',
style: TextStyle(
fontSize: isMobile ? 12 : 14,
color: AppColors.textSecondary,
),
),
Divider(height: isMobile ? 20 : 28),
CustomTextField(
hintText: 'Your Username',
prefixIcon: Icons.person_outline,
controller: _usernameCtrl,
validator: (v) =>
AppValidators.validateRequired(v, 'Username'),
),
const SizedBox(height: AppSizes.p16),
Divider(height: isMobile ? 20 : 28),
const SizedBox(height: AppSizes.p8),
CustomTextField(
hintText: 'New Password',
prefixIcon: Icons.lock_outline,
controller: _newPassCtrl,
isPassword: true,
obscureText: _obscurePasswords,
onToggleVisibility: () => setState(() => _obscurePasswords = !_obscurePasswords),
validator: AppValidators.validatePasswordComplexity,
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (v) => setState(() {}),
),
if (_newPassCtrl.text.isNotEmpty) ...[
PasswordStrengthIndicator(password: _newPassCtrl.text),
],
const SizedBox(height: AppSizes.p24),
CustomTextField(
hintText: 'Confirm New Password',
prefixIcon: Icons.lock_outline,
controller: _confirmPassCtrl,
isPassword: true,
obscureText: _obscurePasswords,
onToggleVisibility: () => setState(() => _obscurePasswords = !_obscurePasswords),
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (v) => setState(() {}),
validator: (v) {
if (v == null || v.isEmpty) return 'Confirm Password is required';
if (v != _newPassCtrl.text) return 'Passwords do not match';
return null;
},
),
SizedBox(height: isMobile ? 16 : 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('CANCEL'),
),
const SizedBox(width: 12),
SizedBox(
width: isMobile ? 130 : 140,
child: PrimaryButton(
label: 'SUBMIT',
isLoading: _isLoading,
onPressed: _handleSubmit,
),
),
],
),
],
),
),
),
),
),
);
}
}
class _NetworkScanDialog extends StatefulWidget {
const _NetworkScanDialog();
@override
State<_NetworkScanDialog> createState() => _NetworkScanDialogState();
}
class _NetworkScanDialogState extends State<_NetworkScanDialog> {
String _status = 'Scanning local network…';
@override
void initState() {
super.initState();
_scan();
}
Future<void> _scan() async {
final found = await ServerDiscoveryService.discover(
onProgress: (subnet, scanned, total) {
if (mounted) {
setState(() => _status = 'Scanning ${subnet}x … ($scanned/$total)');
}
},
);
if (!mounted) return;
Navigator.pop(context);
if (found != null) {
ApiConstants.setBaseUrl(found);
await ServerDiscoveryService.save(ApiConstants.baseUrl);
if (!mounted) return;
showSuccessDialog(
context,
title: 'Server Connected',
message: 'Connected to local server: $found',
);
} else {
final tunnelAlive =
await ServerDiscoveryService.ping(ApiConstants.tunnelUrl);
if (tunnelAlive) {
ApiConstants.setBaseUrl(ApiConstants.tunnelUrl);
await ServerDiscoveryService.save(ApiConstants.baseUrl);
if (!mounted) return;
showSuccessDialog(
context,
title: 'Connected to Tunnel',
message:
'Local server not found. Connected to tunnel domain: ${ApiConstants.tunnelUrl}',
);
} else {
if (!mounted) return;
showErrorDialog(
context,
'Server Not Found',
'No TIS RMS server found on local network and tunnel domain (${ApiConstants.tunnelUrl}) is unreachable.',
);
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Local Network Scan'),
content: Row(
children: [
const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
const SizedBox(width: 20),
Expanded(child: Text(_status)),
],
),
);
}
}