fix: resolve windows documentation crash and refine theme toggle and UI indicators

This commit is contained in:
ssbg04 committed 2026-10-09 02:47:12 +08:00
1 parent f890058cf3
commit 0e99b73989
6 files changed
+294 -188

No files matched your search

@@ -60,11 +60,15 @@ class DocumentationServerService {
response.add(bytes);
await response.close();
} catch (_) {
final response = request.response;
response.statusCode = HttpStatus.notFound;
response.headers.contentType = ContentType.text;
response.write('Document asset not found');
await response.close();
try {
final response = request.response;
response.statusCode = HttpStatus.notFound;
response.headers.contentType = ContentType.text;
response.write('Document asset not found');
await response.close();
} catch (_) {
// Socket already closed by client
}
}
}
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_windows/webview_windows.dart';
@@ -242,6 +244,7 @@ class _WindowsDocView extends StatefulWidget {
class _WindowsDocViewState extends State<_WindowsDocView> {
final WebviewController _controller = WebviewController();
final List<StreamSubscription> _subscriptions = [];
bool _isInitialized = false;
bool _hasError = false;
String _errorMessage = '';
@@ -255,13 +258,46 @@ class _WindowsDocViewState extends State<_WindowsDocView> {
Future<void> _initWebview() async {
try {
// 1. Verify WebView2 Runtime availability before calling initialize to prevent native C++ crash
final version = await WebviewController.getWebViewVersion();
if (version == null || version.trim().isEmpty) {
if (mounted) {
setState(() {
_hasError = true;
_errorMessage =
'Microsoft Edge WebView2 Runtime is not installed on this computer.\n\n'
'You can view the documentation in your default web browser or install the WebView2 Runtime.';
});
}
return;
}
// 2. Initialize environment with a writable user data path in LocalAppData
// (prevents permission denied crashes when running from Program Files)
try {
final appSupport = await getApplicationSupportDirectory();
final webviewDataDir = Directory(
'${appSupport.path}${Platform.pathSeparator}tis_rms_webview2',
);
if (!await webviewDataDir.exists()) {
await webviewDataDir.create(recursive: true);
}
await WebviewController.initializeEnvironment(
userDataPath: webviewDataDir.path,
);
} catch (_) {
// Environment may already be initialized; proceed safely
}
// 3. Initialize controller
await _controller.initialize();
_controller.title.listen((title) {
_subscriptions.add(_controller.title.listen((title) {
if (mounted) setState(() => _pageTitle = title);
});
}));
_controller.url.listen((url) {
_subscriptions.add(_controller.url.listen((url) {
if (!mounted) return;
// Intercept external links clicked inside WebView
if (!url.startsWith('http://127.0.0.1') &&
!url.startsWith('http://localhost') &&
@@ -269,11 +305,12 @@ class _WindowsDocViewState extends State<_WindowsDocView> {
_controller.stop();
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
});
}));
await _controller.setBackgroundColor(
widget.isDark ? const Color(0xFF161D19) : Colors.white,
);
await _controller.setPopupWindowPolicy(WebviewPopupWindowPolicy.deny);
await _controller.loadUrl(widget.url);
if (mounted) {
@@ -286,8 +323,8 @@ class _WindowsDocViewState extends State<_WindowsDocView> {
setState(() {
_hasError = true;
_errorMessage =
'Microsoft Edge WebView2 runtime error: $e.\n'
'Please verify WebView2 Runtime is installed.';
'Microsoft Edge WebView2 initialization error: $e.\n\n'
'You can open the documentation in your web browser instead.';
});
}
}
@@ -295,60 +332,130 @@ class _WindowsDocViewState extends State<_WindowsDocView> {
@override
void dispose() {
_controller.dispose();
for (final sub in _subscriptions) {
sub.cancel();
}
_subscriptions.clear();
if (_isInitialized) {
try {
_controller.dispose();
} catch (e) {
debugPrint('[DocumentationScreen] webview dispose error: $e');
}
}
super.dispose();
}
Future<void> _openExternal() async {
try {
await launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open browser: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_hasError) {
return Center(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning_amber_rounded, size: 48, color: Colors.orange),
const SizedBox(height: 12),
Text(
'WebView2 Required',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: widget.isDark ? Colors.white : AppColors.textPrimary,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
color: widget.isDark ? AppColors.darkSurfaceCard : Colors.white,
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.menu_book_rounded, size: 44, color: Colors.amber),
),
const SizedBox(height: 18),
Text(
'Documentation Viewer',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: widget.isDark ? Colors.white : AppColors.textPrimary,
),
),
const SizedBox(height: 10),
Text(
_errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.5,
height: 1.5,
color: widget.isDark ? Colors.white70 : AppColors.textSecondary,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _openExternal,
icon: const Icon(Icons.open_in_browser_rounded),
label: const Text('Open in Web Browser'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(46),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () {
launchUrl(
Uri.parse('https://developer.microsoft.com/en-us/microsoft-edge/webview2/'),
mode: LaunchMode.externalApplication,
);
},
icon: const Icon(Icons.download_rounded),
label: const Text('Download WebView2 Runtime'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: () {
setState(() {
_hasError = false;
_isInitialized = false;
});
_initWebview();
},
icon: const Icon(Icons.refresh_rounded),
label: const Text('Retry In-App Viewer'),
),
],
),
),
const SizedBox(height: 8),
Text(
_errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: widget.isDark ? Colors.white70 : AppColors.textSecondary,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
launchUrl(
Uri.parse('https://developer.microsoft.com/en-us/microsoft-edge/webview2/'),
mode: LaunchMode.externalApplication,
);
},
icon: const Icon(Icons.download_rounded),
label: const Text('Download WebView2 Runtime'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
),
),
],
),
),
),
);
}
if (!_isInitialized) {
if (!_isInitialized || !_controller.value.isInitialized) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primaryGreen),
);
@@ -683,45 +683,6 @@ 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,
),
],
),
),
),
),
],
),
);
@@ -1812,34 +1812,95 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final themeNotifier =
ref.watch(themeModeProvider.notifier);
final currentKey = themeNotifier.currentKey;
final isSystem = currentKey == 'system';
final isDark = Theme.of(context).brightness == Brightness.dark;
return Wrap(
spacing: 12,
runSpacing: 12,
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildThemeOptionChip(
label: 'System',
icon: Icons.brightness_auto_rounded,
value: 'system',
currentValue: currentKey,
onSelect: () => themeNotifier
.setThemeMode('system'),
// System Theme Mode Switch Toggle
Row(
children: [
Icon(
Icons.brightness_auto_rounded,
color: isSystem
? AppColors.primaryGreen
: (isDark ? Colors.white54 : Colors.grey.shade600),
size: 22,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'System Theme',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'Match device system theme automatically',
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.darkTextSecondary
: Colors.grey.shade600,
),
),
],
),
),
Switch(
value: isSystem,
activeThumbColor: AppColors.primaryGreen,
onChanged: (val) {
if (val) {
themeNotifier.setThemeMode('system');
} else {
final sysDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
themeNotifier.setThemeMode(sysDark ? 'dark' : 'light');
}
},
),
],
),
_buildThemeOptionChip(
label: 'Light',
icon: Icons.light_mode_rounded,
value: 'light',
currentValue: currentKey,
onSelect: () => themeNotifier
.setThemeMode('light'),
),
_buildThemeOptionChip(
label: 'Dark',
icon: Icons.dark_mode_rounded,
value: 'dark',
currentValue: currentKey,
onSelect: () => themeNotifier
.setThemeMode('dark'),
const SizedBox(height: AppSizes.p16),
// Light / Dark Theme selector (disabled/inactive when System is toggled on)
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isSystem ? 0.38 : 1.0,
child: IgnorePointer(
ignoring: isSystem,
child: Row(
children: [
Expanded(
child: _buildThemeOptionCard(
label: 'Light',
icon: Icons.light_mode_rounded,
value: 'light',
currentValue: currentKey,
isActive: !isSystem,
onSelect: () => themeNotifier.setThemeMode('light'),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildThemeOptionCard(
label: 'Dark',
icon: Icons.dark_mode_rounded,
value: 'dark',
currentValue: currentKey,
isActive: !isSystem,
onSelect: () => themeNotifier.setThemeMode('dark'),
),
),
],
),
),
),
],
);
@@ -2914,35 +2975,59 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
Widget _buildThemeOptionChip({
Widget _buildThemeOptionCard({
required String label,
required IconData icon,
required String value,
required String currentValue,
required bool isActive,
required VoidCallback onSelect,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isSelected = value == currentValue;
return ChoiceChip(
showCheckmark: false,
avatar: Icon(
icon,
size: 18,
color: isSelected ? Colors.white : AppColors.primaryGreen,
),
label: Text(
label,
style: TextStyle(
final isSelected = isActive && (value == currentValue);
return InkWell(
onTap: isActive ? onSelect : null,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? Colors.white
: Theme.of(context).colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
? AppColors.primaryGreen.withValues(alpha: isDark ? 0.2 : 0.1)
: (isDark ? AppColors.darkSurface2 : AppColors.pageBackground),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.primaryGreen
: (isDark ? Colors.white12 : Colors.grey.shade300),
width: isSelected ? 1.8 : 1.0,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 19,
color: isSelected
? AppColors.primaryGreen
: (isDark ? Colors.white70 : AppColors.textSecondary),
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 13.5,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected
? AppColors.primaryGreen
: (isDark ? Colors.white : AppColors.textPrimary),
),
),
],
),
),
selected: isSelected,
selectedColor: AppColors.primaryGreen,
backgroundColor: isDark ? AppColors.darkSurface2 : AppColors.pageBackground,
onSelected: (_) => onSelect(),
);
}
@@ -30,7 +30,6 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
Timer? _quoteTimer;
int _quoteIndex = 0;
String _connectionStatus = 'Connecting to server…';
static const List<String> _entertainingPhrases = [
'Sharpening digital pencils…',
@@ -107,23 +106,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen>
Future<bool> _resolveServer({bool isRetry = false}) async {
try {
final found = await ServerDiscoveryService.resolveServerWithFallback(
onProgress: (status) {
if (mounted) setState(() => _connectionStatus = status);
},
).timeout(
final found = await ServerDiscoveryService.resolveServerWithFallback()
.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;
}
@@ -430,46 +419,6 @@ 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,
),
),
],
),
),
],
),
),