1281 lines
43 KiB
Dart
1281 lines
43 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
import 'package:flutter_animate/flutter_animate.dart';
|
|
import 'dart:async';
|
|
import 'dart:io' show Platform;
|
|
import 'package:speech_to_text/speech_to_text.dart' as stt;
|
|
import 'package:speech_to_text/speech_recognition_error.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import '../providers/auth_provider.dart';
|
|
import '../providers/chat_provider.dart';
|
|
import '../models/message.dart';
|
|
import '../services/vosk_voice_service.dart';
|
|
import '../theme.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../widgets/chart_block.dart';
|
|
import '../widgets/markdown_message.dart';
|
|
|
|
class HomePage extends ConsumerStatefulWidget {
|
|
const HomePage({super.key});
|
|
|
|
@override
|
|
ConsumerState<HomePage> createState() => _HomePageState();
|
|
}
|
|
|
|
class _HomePageState extends ConsumerState<HomePage> {
|
|
final _messageController = TextEditingController();
|
|
final _scrollController = ScrollController();
|
|
final stt.SpeechToText _speech = stt.SpeechToText();
|
|
bool _speechAvailable = false;
|
|
bool _isListening = false;
|
|
bool _voskInitializing = false;
|
|
String _voiceBaseText = '';
|
|
StreamSubscription<String>? _voskPartialSub;
|
|
StreamSubscription<String>? _voskResultSub;
|
|
StreamSubscription<String>? _voskErrorSub;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollToBottom();
|
|
if (Platform.isAndroid) {
|
|
_speechAvailable = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_speech.cancel();
|
|
_voskPartialSub?.cancel();
|
|
_voskResultSub?.cancel();
|
|
_voskErrorSub?.cancel();
|
|
if (Platform.isAndroid) {
|
|
VoskVoiceService.instance.stop();
|
|
}
|
|
_messageController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _initSpeech() async {
|
|
try {
|
|
final available = await _speech.initialize(
|
|
onStatus: (status) {
|
|
if (!mounted) return;
|
|
if (status == 'notListening' || status == 'done') {
|
|
setState(() => _isListening = false);
|
|
}
|
|
},
|
|
onError: (SpeechRecognitionError err) {
|
|
if (!mounted) return;
|
|
debugPrint('[iOS-STT] error: ${err.errorMsg} permanent=${err.permanent}');
|
|
setState(() => _isListening = false);
|
|
},
|
|
debugLogging: true,
|
|
finalTimeout: const Duration(seconds: 5),
|
|
);
|
|
debugPrint('[iOS-STT] initialize -> $available');
|
|
if (mounted) setState(() => _speechAvailable = available);
|
|
} catch (e) {
|
|
debugPrint('[iOS-STT] initialize threw $e');
|
|
if (mounted) setState(() => _speechAvailable = false);
|
|
}
|
|
}
|
|
|
|
String _resolveSpeechLocaleId() {
|
|
final lang = Localizations.localeOf(context).languageCode;
|
|
switch (lang) {
|
|
case 'zh':
|
|
return 'zh_CN';
|
|
case 'th':
|
|
return 'th_TH';
|
|
default:
|
|
return 'en_US';
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleListening() async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
if (Platform.isAndroid) {
|
|
await _toggleListeningVosk(l10n);
|
|
return;
|
|
}
|
|
await _toggleListeningIOS(l10n);
|
|
}
|
|
|
|
Future<void> _toggleListeningVosk(AppLocalizations l10n) async {
|
|
if (_isListening) {
|
|
await VoskVoiceService.instance.stop();
|
|
if (mounted) setState(() => _isListening = false);
|
|
return;
|
|
}
|
|
final micStatus = await Permission.microphone.status;
|
|
var granted = micStatus.isGranted;
|
|
if (!granted && !micStatus.isPermanentlyDenied) {
|
|
final r = await Permission.microphone.request();
|
|
granted = r.isGranted;
|
|
if (!granted && r.isPermanentlyDenied) {
|
|
_showPermissionSettingsSnack(l10n);
|
|
return;
|
|
}
|
|
} else if (micStatus.isPermanentlyDenied) {
|
|
_showPermissionSettingsSnack(l10n);
|
|
return;
|
|
}
|
|
if (!granted) {
|
|
_showVoiceSnack(l10n.voicePermissionDenied);
|
|
return;
|
|
}
|
|
if (_voskInitializing) return;
|
|
if (!VoskVoiceService.instance.isReady) {
|
|
setState(() => _voskInitializing = true);
|
|
_showVoiceLoadingSnack(l10n);
|
|
final ok = await VoskVoiceService.instance.ensureReady();
|
|
if (mounted) setState(() => _voskInitializing = false);
|
|
if (!ok) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
_showVoiceSnack(l10n.voiceError);
|
|
}
|
|
return;
|
|
}
|
|
if (mounted) ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
}
|
|
_voiceBaseText = _messageController.text;
|
|
_voskPartialSub ??= VoskVoiceService.instance.partialStream.listen(_onVoskText);
|
|
_voskResultSub ??= VoskVoiceService.instance.resultStream.listen(_onVoskText);
|
|
_voskErrorSub ??= VoskVoiceService.instance.errorStream.listen((_) {
|
|
if (!mounted) return;
|
|
setState(() => _isListening = false);
|
|
_showVoiceSnack(l10n.voiceError);
|
|
});
|
|
final started = await VoskVoiceService.instance.start();
|
|
if (!started) {
|
|
if (mounted) _showVoiceSnack(l10n.voiceError);
|
|
return;
|
|
}
|
|
if (mounted) setState(() => _isListening = true);
|
|
}
|
|
|
|
void _onVoskText(String text) {
|
|
if (!mounted || text.isEmpty) return;
|
|
final cleaned = text.replaceAll(' ', '');
|
|
final next = _voiceBaseText.isEmpty ? cleaned : '$_voiceBaseText$cleaned';
|
|
_messageController.value = TextEditingValue(
|
|
text: next,
|
|
selection: TextSelection.collapsed(offset: next.length),
|
|
);
|
|
}
|
|
|
|
Future<void> _toggleListeningIOS(AppLocalizations l10n) async {
|
|
if (_isListening) {
|
|
await _speech.stop();
|
|
if (mounted) setState(() => _isListening = false);
|
|
return;
|
|
}
|
|
final micStatus = await Permission.microphone.status;
|
|
debugPrint('[iOS-STT] mic status before request: $micStatus');
|
|
var micGranted = micStatus.isGranted;
|
|
if (!micGranted && !micStatus.isPermanentlyDenied) {
|
|
final r = await Permission.microphone.request();
|
|
debugPrint('[iOS-STT] mic status after request: $r');
|
|
micGranted = r.isGranted;
|
|
if (!micGranted && r.isPermanentlyDenied) {
|
|
_showPermissionSettingsSnack(l10n);
|
|
return;
|
|
}
|
|
} else if (micStatus.isPermanentlyDenied) {
|
|
_showPermissionSettingsSnack(l10n);
|
|
return;
|
|
}
|
|
if (!micGranted) {
|
|
_showVoiceSnack(l10n.voicePermissionDenied);
|
|
return;
|
|
}
|
|
if (!_speechAvailable) {
|
|
await _initSpeech();
|
|
if (!_speechAvailable) {
|
|
_showVoiceSnack(l10n.voiceUnavailable);
|
|
return;
|
|
}
|
|
}
|
|
_voiceBaseText = _messageController.text;
|
|
setState(() => _isListening = true);
|
|
try {
|
|
await _speech.listen(
|
|
onResult: (result) {
|
|
final words = result.recognizedWords;
|
|
if (!mounted) return;
|
|
final next = _voiceBaseText.isEmpty
|
|
? words
|
|
: (words.isEmpty ? _voiceBaseText : '$_voiceBaseText $words');
|
|
_messageController.value = TextEditingValue(
|
|
text: next,
|
|
selection: TextSelection.collapsed(offset: next.length),
|
|
);
|
|
},
|
|
listenFor: const Duration(seconds: 30),
|
|
pauseFor: const Duration(seconds: 3),
|
|
localeId: _resolveSpeechLocaleId(),
|
|
listenOptions: stt.SpeechListenOptions(
|
|
partialResults: true,
|
|
cancelOnError: true,
|
|
listenMode: stt.ListenMode.dictation,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[iOS-STT] listen threw $e');
|
|
if (mounted) {
|
|
setState(() => _isListening = false);
|
|
_showVoiceSnack(l10n.voiceError);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showVoiceLoadingSnack(AppLocalizations l10n) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: AppColors.textPrimary.withOpacity(0.9),
|
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 90),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
duration: const Duration(seconds: 10),
|
|
content: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.voiceModelLoading,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showPermissionSettingsSnack(AppLocalizations l10n) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: AppColors.textPrimary.withOpacity(0.92),
|
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 90),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
duration: const Duration(seconds: 4),
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.mic_off, color: Colors.white, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.voicePermissionDenied,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
action: SnackBarAction(
|
|
label: l10n.settings,
|
|
textColor: AppColors.primaryLight,
|
|
onPressed: () {
|
|
openAppSettings();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showVoiceSnack(String message) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: AppColors.textPrimary.withOpacity(0.9),
|
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 90),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
duration: const Duration(seconds: 2),
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.mic_off, color: Colors.white, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _sendMessage() {
|
|
final text = _messageController.text.trim();
|
|
if (text.isEmpty) return;
|
|
_messageController.clear();
|
|
FocusScope.of(context).unfocus();
|
|
ref.read(chatProvider.notifier).sendMessage(text).then((_) {
|
|
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
|
|
});
|
|
setState(() {});
|
|
}
|
|
|
|
void _openScanner() {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const ScannerPage(),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<_NotificationItem> _buildNotificationItems(BuildContext context) {
|
|
return [
|
|
_NotificationItem(
|
|
icon: Icons.assignment_outlined,
|
|
color: const Color(0xFFF59E0B),
|
|
title: '新工单',
|
|
subtitle: '待接单工单提醒',
|
|
count: 3,
|
|
onTap: () => context.push('/order-work'),
|
|
),
|
|
_NotificationItem(
|
|
icon: Icons.receipt_long_outlined,
|
|
color: const Color(0xFF3B82F6),
|
|
title: '新订单',
|
|
subtitle: '待核销订单提醒',
|
|
count: 2,
|
|
onTap: () => context.push('/order-work?tab=order'),
|
|
),
|
|
_NotificationItem(
|
|
icon: Icons.campaign_outlined,
|
|
color: const Color(0xFF8B5CF6),
|
|
title: '系统通知',
|
|
subtitle: '系统公告与活动消息',
|
|
count: 1,
|
|
onTap: () => context.push('/messages'),
|
|
),
|
|
];
|
|
}
|
|
|
|
Widget _buildNotificationButton(BuildContext context) {
|
|
final items = _buildNotificationItems(context);
|
|
final total = items.fold<int>(0, (sum, e) => sum + e.count);
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
alignment: Alignment.center,
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => _showNotifications(context, items),
|
|
icon: Icon(
|
|
Icons.notifications_none_outlined,
|
|
size: 22,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
if (total > 0)
|
|
Positioned(
|
|
right: 6,
|
|
top: 6,
|
|
child: IgnorePointer(
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error,
|
|
borderRadius: BorderRadius.circular(9),
|
|
),
|
|
)
|
|
.animate(onPlay: (c) => c.repeat())
|
|
.scaleXY(
|
|
begin: 1.0,
|
|
end: 2.1,
|
|
duration: 1400.ms,
|
|
curve: Curves.easeOut,
|
|
)
|
|
.fade(
|
|
begin: 0.55,
|
|
end: 0,
|
|
duration: 1400.ms,
|
|
curve: Curves.easeOut,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
constraints: const BoxConstraints(minWidth: 17, minHeight: 17),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error,
|
|
borderRadius: BorderRadius.circular(9),
|
|
border: Border.all(color: AppColors.surface, width: 1.5),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.error.withOpacity(0.35),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
total > 99 ? '99+' : '$total',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.1,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
|
.scaleXY(
|
|
begin: 1.0,
|
|
end: 1.12,
|
|
duration: 1400.ms,
|
|
curve: Curves.easeInOut,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _showNotifications(BuildContext context, List<_NotificationItem> items) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
isScrollControlled: true,
|
|
builder: (ctx) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.divider,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'消息中心',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
'${items.fold<int>(0, (s, e) => s + e.count)} 条未读',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
...items.map(
|
|
(item) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: Material(
|
|
color: AppColors.background,
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(14),
|
|
onTap: () {
|
|
Navigator.of(ctx).pop();
|
|
item.onTap();
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 44,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: item.color.withOpacity(0.12),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(item.icon, color: item.color, size: 22),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.title,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.subtitle,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (item.count > 0)
|
|
Container(
|
|
margin: const EdgeInsets.only(left: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
'${item.count}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
color: AppColors.textTertiary,
|
|
size: 20,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final authState = ref.watch(authProvider);
|
|
final chatState = ref.watch(chatProvider);
|
|
final user = authState.user;
|
|
|
|
ref.listen(chatProvider, (prev, next) {
|
|
if (next.messages.length != (prev?.messages.length ?? 0)) {
|
|
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
|
|
}
|
|
});
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppColors.background,
|
|
appBar: AppBar(
|
|
backgroundColor: AppColors.surface,
|
|
elevation: 0,
|
|
automaticallyImplyLeading: false,
|
|
title: Row(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Image.asset('assets/logo.png', fit: BoxFit.cover),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.aiAssistant,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.success,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
l10n.online,
|
|
style: TextStyle(fontSize: 11, color: AppColors.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
if (user != null) ...[
|
|
_buildNotificationButton(context),
|
|
IconButton(
|
|
onPressed: () => context.push('/settings'),
|
|
icon: Icon(
|
|
Icons.settings_outlined,
|
|
size: 22,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: chatState.messages.length,
|
|
itemBuilder: (context, index) {
|
|
final message = chatState.messages[index];
|
|
return _buildMessageBubble(message, index, l10n);
|
|
},
|
|
),
|
|
),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.04),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, -4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_buildQuickActions(l10n),
|
|
_buildInputArea(l10n),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageBubble(ChatMessage message, int index, AppLocalizations l10n) {
|
|
final isUser = message.sender == MessageSender.user;
|
|
|
|
if (message.type == MessageType.loading) {
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.chatAiBubble,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation(
|
|
AppColors.primary.withOpacity(0.6),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.thinking,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Align(
|
|
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: isUser ? AppColors.chatUserBubble : AppColors.chatAiBubble,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: const Radius.circular(16),
|
|
topRight: const Radius.circular(16),
|
|
bottomLeft: Radius.circular(isUser ? 16 : 4),
|
|
bottomRight: Radius.circular(isUser ? 4 : 16),
|
|
),
|
|
),
|
|
child: isUser || message.type == MessageType.text
|
|
? Text(
|
|
message.content,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
color: isUser ? Colors.white : AppColors.textPrimary,
|
|
height: 1.5,
|
|
),
|
|
)
|
|
: MarkdownMessage(
|
|
data: message.content,
|
|
builders: {'code': ChartCodeBuilder()},
|
|
styleSheet: MarkdownStyleSheet(
|
|
p: TextStyle(
|
|
fontSize: 15,
|
|
color: AppColors.textPrimary,
|
|
height: 1.6,
|
|
),
|
|
code: TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.primary,
|
|
backgroundColor: AppColors.primary.withOpacity(0.08),
|
|
fontFamily: 'monospace',
|
|
),
|
|
codeblockDecoration: const BoxDecoration(
|
|
color: Colors.transparent,
|
|
),
|
|
codeblockPadding: EdgeInsets.zero,
|
|
blockquote: TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.textSecondary,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
blockquoteDecoration: BoxDecoration(
|
|
border: Border(
|
|
left: BorderSide(
|
|
color: AppColors.primary.withOpacity(0.4),
|
|
width: 3,
|
|
),
|
|
),
|
|
),
|
|
tableHead: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
tableBody: TextStyle(
|
|
color: AppColors.textSecondary,
|
|
),
|
|
tableBorder: TableBorder.all(
|
|
color: AppColors.divider,
|
|
width: 1,
|
|
),
|
|
tableCellsPadding: const EdgeInsets.all(8),
|
|
h1: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
h2: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
h3: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
strong: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_formatTime(message.timestamp),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textTertiary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0, duration: 300.ms);
|
|
}
|
|
|
|
Widget _buildQuickActions(AppLocalizations l10n) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 2),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: _QuickAction(
|
|
icon: Icons.event_note_outlined,
|
|
label: l10n.eventPublish,
|
|
color: const Color(0xFF8B5CF6),
|
|
onTap: () => context.push('/event/publish'),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _QuickAction(
|
|
icon: Icons.assignment_outlined,
|
|
label: l10n.orderWork,
|
|
color: const Color(0xFFF59E0B),
|
|
onTap: () => context.push('/order-work'),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _QuickAction(
|
|
icon: Icons.qr_code_scanner,
|
|
label: l10n.verify,
|
|
color: const Color(0xFF10B981),
|
|
onTap: () => _openScanner(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInputArea(AppLocalizations l10n) {
|
|
final isAndroid = Theme.of(context).platform == TargetPlatform.android;
|
|
return Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 2, 16, isAndroid ? 16 : 4),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_isListening) _buildListeningHint(l10n),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _messageController,
|
|
maxLines: null,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => _sendMessage(),
|
|
decoration: InputDecoration(
|
|
hintText: _isListening ? l10n.voiceListening : l10n.enterMessage,
|
|
hintStyle: TextStyle(
|
|
fontSize: 14,
|
|
color: _isListening
|
|
? AppColors.error.withOpacity(0.85)
|
|
: AppColors.textTertiary,
|
|
),
|
|
filled: true,
|
|
fillColor: AppColors.background,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(28),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(28),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(28),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.fromLTRB(18, 10, 8, 10),
|
|
isDense: true,
|
|
suffixIcon: _buildMicButton(l10n),
|
|
suffixIconConstraints: const BoxConstraints(
|
|
minWidth: 36,
|
|
minHeight: 36,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
GestureDetector(
|
|
onTap: _sendMessage,
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
gradient: AppGradients.primary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.send,
|
|
color: Colors.white,
|
|
size: 17,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMicButton(AppLocalizations l10n) {
|
|
final listening = _isListening;
|
|
final tooltip = listening
|
|
? l10n.voiceListening
|
|
: (_speechAvailable ? l10n.aiAssistant : l10n.voiceUnavailable);
|
|
final icon = Icon(
|
|
listening ? Icons.mic : Icons.mic_none,
|
|
color: listening ? AppColors.error : AppColors.textSecondary,
|
|
size: 22,
|
|
);
|
|
final button = InkWell(
|
|
onTap: _toggleListening,
|
|
customBorder: const CircleBorder(),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(7),
|
|
child: listening
|
|
? icon
|
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
|
.scaleXY(
|
|
begin: 1.0,
|
|
end: 1.15,
|
|
duration: 700.ms,
|
|
curve: Curves.easeInOut,
|
|
)
|
|
: icon,
|
|
),
|
|
);
|
|
return Tooltip(message: tooltip, child: button);
|
|
}
|
|
|
|
Widget _buildListeningHint(AppLocalizations l10n) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppColors.error.withOpacity(0.25), width: 1),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.error,
|
|
shape: BoxShape.circle,
|
|
),
|
|
)
|
|
.animate(onPlay: (c) => c.repeat(reverse: true))
|
|
.fadeIn(begin: 0.35, duration: 600.ms, curve: Curves.easeInOut)
|
|
.scaleXY(begin: 0.8, end: 1.1, duration: 600.ms),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.voiceListening,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatTime(DateTime time) {
|
|
final now = DateTime.now();
|
|
if (time.year == now.year && time.month == now.month && time.day == now.day) {
|
|
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
return '${time.month}/${time.day} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|
|
|
|
class _QuickAction extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final Color color;
|
|
final VoidCallback onTap;
|
|
|
|
const _QuickAction({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.color,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(icon, color: color, size: 20),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NotificationItem {
|
|
final IconData icon;
|
|
final Color color;
|
|
final String title;
|
|
final String subtitle;
|
|
final int count;
|
|
final VoidCallback onTap;
|
|
|
|
const _NotificationItem({
|
|
required this.icon,
|
|
required this.color,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.count,
|
|
required this.onTap,
|
|
});
|
|
}
|
|
|
|
class ScannerPage extends StatefulWidget {
|
|
const ScannerPage({super.key});
|
|
|
|
@override
|
|
State<ScannerPage> createState() => _ScannerPageState();
|
|
}
|
|
|
|
class _ScannerPageState extends State<ScannerPage> {
|
|
bool _isScanned = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Scaffold(
|
|
backgroundColor: Colors.black,
|
|
body: Stack(
|
|
children: [
|
|
MobileScanner(
|
|
onDetect: (capture) {
|
|
if (_isScanned) return;
|
|
final barcodes = capture.barcodes;
|
|
for (final barcode in barcodes) {
|
|
final code = barcode.rawValue;
|
|
if (code != null && code.isNotEmpty) {
|
|
_isScanned = true;
|
|
Navigator.of(context).pop();
|
|
context.push('/scan-result?code=$code');
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
),
|
|
_buildOverlay(),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () => Navigator.of(context).pop(),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Icon(Icons.close, color: Colors.white),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
l10n.scanning,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
const SizedBox(width: 44),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildOverlay() {
|
|
return Center(
|
|
child: Container(
|
|
width: 260,
|
|
height: 260,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: AppColors.primary.withOpacity(0.8),
|
|
width: 2,
|
|
),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
child: _buildCorner(true, true),
|
|
),
|
|
Positioned(
|
|
top: 0,
|
|
right: 0,
|
|
child: _buildCorner(true, false),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
child: _buildCorner(false, true),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
right: 0,
|
|
child: _buildCorner(false, false),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCorner(bool top, bool left) {
|
|
return Container(
|
|
width: 30,
|
|
height: 30,
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: top ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none,
|
|
bottom: !top ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none,
|
|
left: left ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none,
|
|
right: !left ? const BorderSide(color: AppColors.primary, width: 4) : BorderSide.none,
|
|
),
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: top && left ? const Radius.circular(12) : Radius.zero,
|
|
topRight: top && !left ? const Radius.circular(12) : Radius.zero,
|
|
bottomLeft: !top && left ? const Radius.circular(12) : Radius.zero,
|
|
bottomRight: !top && !left ? const Radius.circular(12) : Radius.zero,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|