feat: 完成 iAgent App 三大功能 + 修复租户上下文

## 功能一:设置页(完整实现)
- 新增浅色主题(lightTheme),支持深色/浅色/跟随系统三种模式
- app.dart 接入 themeMode 动态切换
- 设置页完整重写:个人信息编辑、修改密码、主题切换、通知开关
- 新增 settings_remote_datasource 对接后端 admin/settings API
- settings_providers 新增 AccountProfileNotifier 管理远程个人资料

## 功能二:语音通话(音频集成)
- 添加 flutter_sound 依赖,创建 PcmPlayer 流式 PCM 播放器
- agent_call_page 替换空壳:真实麦克风采集(record + GTCRN 降噪)
- 真实 PCM 16kHz 流式播放,基于 RMS 能量驱动波形动画
- 修复 WebSocket URL 路径:/ws/voice/ → /api/v1/voice/ws/
- voice_repository_impl 支持后端返回相对路径自动拼接

## 功能三:推送通知(WebSocket MVP)
- 添加 flutter_local_notifications + socket_io_client 依赖
- 创建 AppNotification 实体、NotificationService(Socket.IO 连接 comm-service)
- 通知 providers:列表管理 + 未读计数
- 登录后自动连接通知服务,登出断开
- 底部导航 Alerts 标签添加未读角标(Badge)
- AndroidManifest 添加 POST_NOTIFICATIONS 权限
- main.dart 初始化本地通知插件

## 修复:租户上下文未初始化(500错误)
- 根因:登录后未设置 currentTenantIdProvider,导致 X-Tenant-Id 头缺失
- Flutter 端:login() 成功后从 JWT 设置 tenantId,logout 时清除
- 后端:tenant-context.middleware 增加 JWT tenantId 回退逻辑
- AuthUser 模型新增 tenantId 字段解析

新增 5 个文件,修改 16 个文件,添加 3 个依赖包

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 01:10:52 -08:00
parent 6a293d734b
commit 092a561867
21 changed files with 1071 additions and 158 deletions

View File

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:label="iAgent" android:label="iAgent"

View File

@ -15,11 +15,21 @@ import io.flutter.embedding.engine.FlutterEngine;
public final class GeneratedPluginRegistrant { public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant"; private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) { public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try { try {
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin()); flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e); Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
} }
try {
flutterEngine.getPlugins().add(new xyz.canardoux.fluttersound.FlutterSound());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_sound, xyz.canardoux.fluttersound.FlutterSound", e);
}
try { try {
flutterEngine.getPlugins().add(new com.eyedeadevelopment.fluttertts.FlutterTtsPlugin()); flutterEngine.getPlugins().add(new com.eyedeadevelopment.fluttertts.FlutterTtsPlugin());
} catch (Exception e) { } catch (Exception e) {

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'features/settings/presentation/providers/settings_providers.dart';
class IT0App extends ConsumerWidget { class IT0App extends ConsumerWidget {
const IT0App({super.key}); const IT0App({super.key});
@ -9,10 +10,13 @@ class IT0App extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router( return MaterialApp.router(
title: 'iAgent', title: 'iAgent',
theme: AppTheme.darkTheme, theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
routerConfig: router, routerConfig: router,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );

View File

@ -0,0 +1,58 @@
import 'dart:typed_data';
import 'package:flutter_sound/flutter_sound.dart';
/// Wraps [FlutterSoundPlayer] for streaming raw PCM 16kHz mono playback.
///
/// Usage:
/// final player = PcmPlayer();
/// await player.init();
/// player.feed(pcmBytes); // call repeatedly as data arrives
/// await player.dispose();
class PcmPlayer {
FlutterSoundPlayer? _player;
bool _initialized = false;
/// Open the player and start a streaming session.
Future<void> init() async {
if (_initialized) return;
_player = FlutterSoundPlayer();
await _player!.openPlayer();
await _player!.startPlayerFromStream(
codec: Codec.pcm16,
sampleRate: 16000,
numChannels: 1,
interleaved: false,
bufferSize: 8192,
);
_initialized = true;
}
/// Feed raw PCM 16-bit signed LE mono 16 kHz data for playback.
Future<void> feed(Uint8List pcmData) async {
if (!_initialized || _player == null) return;
// ignore: deprecated_member_use
await _player!.feedUint8FromStream(pcmData);
}
/// Toggle speaker mode (earpiece vs loudspeaker).
Future<void> setSpeakerOn(bool on) async {
// flutter_sound doesn't expose a direct speaker toggle;
// this would typically use audio_session or method channel.
// Placeholder for future platform-specific implementation.
}
/// Stop playback and release resources.
Future<void> dispose() async {
if (_player != null) {
try {
await _player!.stopPlayer();
} catch (_) {}
await _player!.closePlayer();
_player = null;
}
_initialized = false;
}
}

View File

@ -41,6 +41,13 @@ class ApiEndpoints {
// Voice // Voice
static const String transcribe = '$voice/transcribe'; static const String transcribe = '$voice/transcribe';
// Admin Settings
static const String adminSettings = '/api/v1/admin/settings';
static const String adminAccount = '$adminSettings/account';
static const String adminAccountPassword = '$adminSettings/account/password';
static const String adminTheme = '$adminSettings/theme';
static const String adminNotifications = '$adminSettings/notifications';
// WebSocket // WebSocket
static const String wsTerminal = '/ws/terminal'; static const String wsTerminal = '/ws/terminal';
} }

View File

@ -11,6 +11,7 @@ import '../../features/servers/presentation/pages/servers_page.dart';
import '../../features/alerts/presentation/pages/alerts_page.dart'; import '../../features/alerts/presentation/pages/alerts_page.dart';
import '../../features/settings/presentation/pages/settings_page.dart'; import '../../features/settings/presentation/pages/settings_page.dart';
import '../../features/terminal/presentation/pages/terminal_page.dart'; import '../../features/terminal/presentation/pages/terminal_page.dart';
import '../../features/notifications/presentation/providers/notification_providers.dart';
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
return GoRouter( return GoRouter(
@ -65,24 +66,41 @@ final routerProvider = Provider<GoRouter>((ref) {
); );
}); });
class ScaffoldWithNav extends StatelessWidget { class ScaffoldWithNav extends ConsumerWidget {
final Widget child; final Widget child;
const ScaffoldWithNav({super.key, required this.child}); const ScaffoldWithNav({super.key, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationCountProvider);
return Scaffold( return Scaffold(
body: child, body: child,
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
destinations: const [ destinations: [
NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'), const NavigationDestination(
NavigationDestination(icon: Icon(Icons.chat), label: 'Chat'), icon: Icon(Icons.dashboard), label: '仪表盘'),
NavigationDestination(icon: Icon(Icons.task), label: 'Tasks'), const NavigationDestination(icon: Icon(Icons.chat), label: '对话'),
NavigationDestination(icon: Icon(Icons.notifications), label: 'Alerts'), const NavigationDestination(icon: Icon(Icons.task), label: '任务'),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), NavigationDestination(
icon: Badge(
isLabelVisible: unreadCount > 0,
label: Text('$unreadCount'),
child: const Icon(Icons.notifications),
),
label: '告警',
),
const NavigationDestination(
icon: Icon(Icons.settings), label: '设置'),
], ],
onDestinationSelected: (index) { onDestinationSelected: (index) {
final routes = ['/dashboard', '/chat', '/tasks', '/alerts', '/settings']; final routes = [
'/dashboard',
'/chat',
'/tasks',
'/alerts',
'/settings'
];
GoRouter.of(context).go(routes[index]); GoRouter.of(context).go(routes[index]);
}, },
), ),

View File

@ -0,0 +1,95 @@
import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:socket_io_client/socket_io_client.dart' as io;
import '../../features/notifications/domain/entities/app_notification.dart';
/// Service that connects to the comm-service Socket.IO gateway
/// and displays local notifications for real-time push events.
class NotificationService {
final String baseUrl;
final String? accessToken;
final FlutterLocalNotificationsPlugin _localNotifications;
io.Socket? _socket;
String? _tenantId;
final _controller = StreamController<AppNotification>.broadcast();
int _notificationId = 0;
NotificationService({
required this.baseUrl,
this.accessToken,
required FlutterLocalNotificationsPlugin localNotifications,
}) : _localNotifications = localNotifications;
/// Stream of incoming notifications.
Stream<AppNotification> get notifications => _controller.stream;
/// Connect to the comm-service WebSocket gateway.
void connect(String tenantId) {
_tenantId = tenantId;
disconnect();
_socket = io.io(
'$baseUrl/ws/comm',
io.OptionBuilder()
.setTransports(['websocket'])
.enableAutoConnect()
.setExtraHeaders(
accessToken != null
? {'Authorization': 'Bearer $accessToken'}
: {},
)
.build(),
);
_socket!.onConnect((_) {
// Join the tenant room
_socket!.emit('join', {'room': 'tenant:$_tenantId'});
});
// Listen for notification events
_socket!.on('notification', (data) {
if (data is Map<String, dynamic>) {
final notification = AppNotification.fromJson(data);
_controller.add(notification);
_showLocalNotification(notification);
}
});
_socket!.onDisconnect((_) {});
_socket!.onConnectError((_) {});
}
/// Disconnect from the WebSocket gateway.
void disconnect() {
_socket?.dispose();
_socket = null;
}
/// Show a local notification on the device.
Future<void> _showLocalNotification(AppNotification notification) async {
const androidDetails = AndroidNotificationDetails(
'it0_notifications',
'运维通知',
channelDescription: 'iAgent 运维告警和通知',
importance: Importance.high,
priority: Priority.high,
);
const details = NotificationDetails(android: androidDetails);
await _localNotifications.show(
_notificationId++,
notification.title,
notification.body,
details,
payload: notification.actionRoute,
);
}
/// Clean up resources.
void dispose() {
disconnect();
_controller.close();
}
}

View File

@ -30,4 +30,34 @@ class AppTheme {
), ),
), ),
); );
static ThemeData get lightTheme => ThemeData(
brightness: Brightness.light,
useMaterial3: true,
colorScheme: ColorScheme.light(
primary: AppColors.primary,
secondary: AppColors.secondary,
surface: const Color(0xFFF8FAFC),
error: AppColors.error,
),
scaffoldBackgroundColor: const Color(0xFFF1F5F9),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
elevation: 0,
foregroundColor: Color(0xFF1E293B),
),
cardTheme: CardThemeData(
color: Colors.white,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF8FAFC),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
),
);
} }

View File

@ -21,7 +21,16 @@ class VoiceRepositoryImpl implements VoiceRepository {
// If the backend didn't provide a WebSocket URL, construct one // If the backend didn't provide a WebSocket URL, construct one
if (session.websocketUrl == null) { if (session.websocketUrl == null) {
return session.copyWith( return session.copyWith(
websocketUrl: '${_config.wsBaseUrl}/ws/voice/${session.id}', websocketUrl: '${_config.wsBaseUrl}/api/v1/voice/ws/${session.id}',
);
}
// If the backend returned a relative path, prepend the wsBaseUrl host
if (session.websocketUrl!.startsWith('/')) {
final uri = Uri.parse(_config.wsBaseUrl);
return session.copyWith(
websocketUrl:
'${uri.scheme}://${uri.host}:${uri.port}${session.websocketUrl}',
); );
} }

View File

@ -3,16 +3,15 @@ import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:record/record.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import '../../../../core/audio/pcm_player.dart';
import '../../../../core/audio/speech_enhancer.dart';
import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/api_endpoints.dart';
import '../../../../core/config/app_config.dart'; import '../../../../core/config/app_config.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
// ---------------------------------------------------------------------------
// TODO 42 Agent call page: voice call with WebSocket audio streaming
// ---------------------------------------------------------------------------
/// Tracks the current state of the voice call. /// Tracks the current state of the voice call.
enum _CallPhase { ringing, connecting, active, ended } enum _CallPhase { ringing, connecting, active, ended }
@ -27,28 +26,35 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
_CallPhase _phase = _CallPhase.ringing; _CallPhase _phase = _CallPhase.ringing;
String? _sessionId; String? _sessionId;
String? _wsUrl;
WebSocketChannel? _audioChannel; WebSocketChannel? _audioChannel;
StreamSubscription? _audioSubscription; StreamSubscription? _audioSubscription;
// Audio capture
late final AudioRecorder _recorder;
final SpeechEnhancer _enhancer = SpeechEnhancer();
StreamSubscription<List<int>>? _micSubscription;
// Audio playback
final PcmPlayer _pcmPlayer = PcmPlayer();
// Call duration // Call duration
final Stopwatch _stopwatch = Stopwatch(); final Stopwatch _stopwatch = Stopwatch();
Timer? _durationTimer; Timer? _durationTimer;
String _durationLabel = '00:00'; String _durationLabel = '00:00';
// Waveform placeholder // Waveform
late AnimationController _waveController; late AnimationController _waveController;
final List<double> _waveHeights = List.generate(20, (_) => 0.3); final List<double> _waveHeights = List.generate(20, (_) => 0.3);
final _random = Random();
Timer? _waveTimer;
// Mute state // Mute / speaker state
bool _isMuted = false; bool _isMuted = false;
bool _isSpeakerOn = false; bool _isSpeakerOn = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_recorder = AudioRecorder();
_enhancer.init();
_waveController = AnimationController( _waveController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
@ -59,40 +65,47 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
// Call lifecycle // Call lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Accept call: create voice session, get websocket_url, connect. /// Accept call: create voice session, connect WebSocket, start mic + player.
Future<void> _acceptCall() async { Future<void> _acceptCall() async {
setState(() => _phase = _CallPhase.connecting); setState(() => _phase = _CallPhase.connecting);
try { try {
final dio = ref.read(dioClientProvider); final dio = ref.read(dioClientProvider);
final config = ref.read(appConfigProvider);
final response = await dio.post('${ApiEndpoints.voice}/sessions'); final response = await dio.post('${ApiEndpoints.voice}/sessions');
final data = response.data as Map<String, dynamic>; final data = response.data as Map<String, dynamic>;
_sessionId = data['id'] as String? ?? data['sessionId'] as String?; _sessionId = data['id'] as String? ?? data['sessionId'] as String?;
_wsUrl = data['websocket_url'] as String? ?? data['ws_url'] as String?;
if (_wsUrl == null || _sessionId == null) { // Build WebSocket URL prefer backend-returned path
// Construct the WS URL from config if the server didn't provide one String wsUrl;
final config = ref.read(appConfigProvider); final backendWsUrl =
_wsUrl ??= '${config.wsBaseUrl}/ws/voice/$_sessionId'; data['websocket_url'] as String? ?? data['ws_url'] as String?;
if (backendWsUrl != null && backendWsUrl.startsWith('/')) {
// Relative path from backend prepend wsBaseUrl host
final uri = Uri.parse(config.wsBaseUrl);
wsUrl = '${uri.scheme}://${uri.host}:${uri.port}$backendWsUrl';
} else if (backendWsUrl != null) {
wsUrl = backendWsUrl;
} else {
wsUrl = '${config.wsBaseUrl}/api/v1/voice/ws/$_sessionId';
} }
// Connect the audio WebSocket // Connect WebSocket
_audioChannel = WebSocketChannel.connect(Uri.parse(_wsUrl!)); _audioChannel = WebSocketChannel.connect(Uri.parse(wsUrl));
// Listen for incoming audio data from the agent // Listen for incoming audio from the agent
_audioSubscription = _audioChannel!.stream.listen( _audioSubscription = _audioChannel!.stream.listen(
(data) { (data) => _onAudioReceived(data),
_onAudioReceived(data);
},
onDone: () => _onCallEnded(), onDone: () => _onCallEnded(),
onError: (_) => _onCallEnded(), onError: (_) => _onCallEnded(),
); );
// Start sending simulated mic audio (real implementation would use // Initialize audio playback
// platform microphone via record/audio_session packages and stream await _pcmPlayer.init();
// PCM 16kHz chunks).
_startMicCapture(); // Start mic capture
await _startMicCapture();
// Start duration timer // Start duration timer
_stopwatch.start(); _stopwatch.start();
@ -105,23 +118,14 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
}); });
}); });
// Animate waveform
_waveController.repeat(reverse: true); _waveController.repeat(reverse: true);
_waveTimer = Timer.periodic(const Duration(milliseconds: 120), (_) {
if (!mounted || _phase != _CallPhase.active) return;
setState(() {
for (int i = 0; i < _waveHeights.length; i++) {
_waveHeights[i] = 0.15 + _random.nextDouble() * 0.7;
}
});
});
setState(() => _phase = _CallPhase.active); setState(() => _phase = _CallPhase.active);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to start call: $e'), content: Text('通话连接失败: $e'),
backgroundColor: AppColors.error, backgroundColor: AppColors.error,
), ),
); );
@ -130,66 +134,105 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
} }
} }
/// Simulate microphone capture in production this would use the `record` /// Start real microphone capture: record PCM 16kHz mono, denoise, send over WS.
/// package or platform channels to capture raw PCM 16kHz audio and stream it Future<void> _startMicCapture() async {
/// over the WebSocket. final hasPermission = await _recorder.hasPermission();
void _startMicCapture() { if (!hasPermission) return;
// Placeholder: send a small silent PCM buffer every 100ms to keep the
// WebSocket alive. A real implementation would hook into the mic stream. final stream = await _recorder.startStream(const RecordConfig(
Timer.periodic(const Duration(milliseconds: 100), (timer) { encoder: AudioEncoder.pcm16bits,
if (_phase != _CallPhase.active || _isMuted) { sampleRate: 16000,
if (_phase == _CallPhase.ended) timer.cancel(); numChannels: 1,
return; noiseSuppress: true,
} autoGain: true,
));
_micSubscription = stream.listen((chunk) {
if (_phase != _CallPhase.active || _isMuted) return;
try { try {
// Send 16kHz * 0.1s * 2 bytes = 3200 bytes of silence // Denoise each chunk with GTCRN
final silence = Uint8List(3200); final pcm = Uint8List.fromList(chunk);
_audioChannel?.sink.add(silence); final denoised = _enhancer.enhance(pcm);
} catch (_) { _audioChannel?.sink.add(denoised);
timer.cancel(); } catch (_) {}
}
}); });
} }
/// Handle incoming audio from the agent side. /// Handle incoming audio from the agent side.
void _onAudioReceived(dynamic data) { void _onAudioReceived(dynamic data) {
// In a full implementation this would be piped to an audio player
// (e.g. flutter_sound or just_audio) for real-time playback.
// For now we simply trigger the waveform animation to indicate
// that audio data is being received.
if (!mounted || _phase != _CallPhase.active) return; if (!mounted || _phase != _CallPhase.active) return;
Uint8List pcmData;
if (data is Uint8List) {
pcmData = data;
} else if (data is List<int>) {
pcmData = Uint8List.fromList(data);
} else {
return;
}
// Feed to player for real-time playback
_pcmPlayer.feed(pcmData);
// Drive waveform from actual audio energy (RMS)
_updateWaveform(pcmData);
}
/// Calculate RMS from PCM data and update waveform bars.
void _updateWaveform(Uint8List pcmData) {
if (pcmData.length < 4) return;
final byteData = ByteData.sublistView(pcmData);
final sampleCount = pcmData.length ~/ 2;
// Compute overall RMS
double sum = 0;
for (int i = 0; i < sampleCount; i++) {
final s = byteData.getInt16(i * 2, Endian.little).toDouble();
sum += s * s;
}
final rms = sqrt(sum / sampleCount) / 32768.0; // normalize to [0,1]
setState(() { setState(() {
final rand = Random();
for (int i = 0; i < _waveHeights.length; i++) { for (int i = 0; i < _waveHeights.length; i++) {
_waveHeights[i] = 0.2 + _random.nextDouble() * 0.8; _waveHeights[i] =
(rms * 2.0 + rand.nextDouble() * 0.2).clamp(0.1, 1.0);
} }
}); });
} }
/// End the call and clean up resources. /// End the call and clean up all resources.
Future<void> _endCall() async { Future<void> _endCall() async {
setState(() => _phase = _CallPhase.ended); setState(() => _phase = _CallPhase.ended);
_stopwatch.stop(); _stopwatch.stop();
_durationTimer?.cancel(); _durationTimer?.cancel();
_waveTimer?.cancel();
_waveController.stop(); _waveController.stop();
_audioSubscription?.cancel();
// Stop mic
await _micSubscription?.cancel();
_micSubscription = null;
try {
await _recorder.stop();
} catch (_) {}
// Stop playback
await _pcmPlayer.dispose();
// Close WebSocket
_audioSubscription?.cancel();
try { try {
await _audioChannel?.sink.close(); await _audioChannel?.sink.close();
} catch (_) {} } catch (_) {}
// Delete the voice session on the server // Delete voice session on the server
if (_sessionId != null) { if (_sessionId != null) {
try { try {
final dio = ref.read(dioClientProvider); final dio = ref.read(dioClientProvider);
await dio.delete('${ApiEndpoints.voice}/sessions/$_sessionId'); await dio.delete('${ApiEndpoints.voice}/sessions/$_sessionId');
} catch (_) { } catch (_) {}
// Best-effort cleanup
}
} }
if (mounted) { if (mounted) {
// Brief delay so the user sees "Call ended" before popping
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} }
@ -212,25 +255,20 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
child: Column( child: Column(
children: [ children: [
const Spacer(flex: 2), const Spacer(flex: 2),
// Agent avatar / icon
_buildAvatar(), _buildAvatar(),
const SizedBox(height: 24), const SizedBox(height: 24),
// Status text
Text( Text(
_statusText, _statusText,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), style:
const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_subtitleText, _subtitleText,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), style: const TextStyle(
color: AppColors.textSecondary, fontSize: 15),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
// Duration
if (_phase == _CallPhase.active) if (_phase == _CallPhase.active)
Text( Text(
_durationLabel, _durationLabel,
@ -241,15 +279,9 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
letterSpacing: 4, letterSpacing: 4,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Waveform visualization placeholder
if (_phase == _CallPhase.active) _buildWaveform(), if (_phase == _CallPhase.active) _buildWaveform(),
const Spacer(flex: 3), const Spacer(flex: 3),
// Control buttons
_buildControls(), _buildControls(),
const SizedBox(height: 48), const SizedBox(height: 48),
], ],
@ -261,24 +293,24 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
String get _statusText { String get _statusText {
switch (_phase) { switch (_phase) {
case _CallPhase.ringing: case _CallPhase.ringing:
return 'iAgent Calling'; return 'iAgent 语音通话';
case _CallPhase.connecting: case _CallPhase.connecting:
return 'Connecting...'; return '连接中...';
case _CallPhase.active: case _CallPhase.active:
return 'iAgent'; return 'iAgent';
case _CallPhase.ended: case _CallPhase.ended:
return 'Call Ended'; return '通话结束';
} }
} }
String get _subtitleText { String get _subtitleText {
switch (_phase) { switch (_phase) {
case _CallPhase.ringing: case _CallPhase.ringing:
return 'Incoming voice call'; return '智能运维语音助手';
case _CallPhase.connecting: case _CallPhase.connecting:
return 'Setting up secure channel'; return '正在建立安全连接';
case _CallPhase.active: case _CallPhase.active:
return 'Voice call in progress'; return '语音通话中';
case _CallPhase.ended: case _CallPhase.ended:
return _durationLabel; return _durationLabel;
} }
@ -327,7 +359,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
height: 10 + _waveHeights[i] * 50, height: 10 + _waveHeights[i] * 50,
margin: const EdgeInsets.symmetric(horizontal: 2), margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.6 + _waveHeights[i] * 0.4), color: AppColors.primary
.withOpacity(0.6 + _waveHeights[i] * 0.4),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
); );
@ -342,7 +375,6 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Decline
FloatingActionButton( FloatingActionButton(
heroTag: 'decline', heroTag: 'decline',
backgroundColor: AppColors.error, backgroundColor: AppColors.error,
@ -350,7 +382,6 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
child: const Icon(Icons.call_end, color: Colors.white), child: const Icon(Icons.call_end, color: Colors.white),
), ),
const SizedBox(width: 40), const SizedBox(width: 40),
// Accept
FloatingActionButton( FloatingActionButton(
heroTag: 'accept', heroTag: 'accept',
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
@ -367,15 +398,13 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Mute
_CircleButton( _CircleButton(
icon: _isMuted ? Icons.mic_off : Icons.mic, icon: _isMuted ? Icons.mic_off : Icons.mic,
label: _isMuted ? 'Unmute' : 'Mute', label: _isMuted ? '取消静音' : '静音',
isActive: _isMuted, isActive: _isMuted,
onTap: () => setState(() => _isMuted = !_isMuted), onTap: () => setState(() => _isMuted = !_isMuted),
), ),
const SizedBox(width: 24), const SizedBox(width: 24),
// End call
FloatingActionButton( FloatingActionButton(
heroTag: 'end', heroTag: 'end',
backgroundColor: AppColors.error, backgroundColor: AppColors.error,
@ -383,29 +412,35 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
child: const Icon(Icons.call_end, color: Colors.white), child: const Icon(Icons.call_end, color: Colors.white),
), ),
const SizedBox(width: 24), const SizedBox(width: 24),
// Speaker
_CircleButton( _CircleButton(
icon: _isSpeakerOn ? Icons.volume_up : Icons.volume_down, icon: _isSpeakerOn ? Icons.volume_up : Icons.volume_down,
label: 'Speaker', label: '扬声器',
isActive: _isSpeakerOn, isActive: _isSpeakerOn,
onTap: () => setState(() => _isSpeakerOn = !_isSpeakerOn), onTap: () {
setState(() => _isSpeakerOn = !_isSpeakerOn);
_pcmPlayer.setSpeakerOn(_isSpeakerOn);
},
), ),
], ],
); );
case _CallPhase.ended: case _CallPhase.ended:
return const Icon(Icons.call_end, size: 48, color: AppColors.textMuted); return const Icon(Icons.call_end,
size: 48, color: AppColors.textMuted);
} }
} }
@override @override
void dispose() { void dispose() {
_durationTimer?.cancel(); _durationTimer?.cancel();
_waveTimer?.cancel();
_waveController.dispose(); _waveController.dispose();
_stopwatch.stop(); _stopwatch.stop();
_micSubscription?.cancel();
_recorder.dispose();
_enhancer.dispose();
_audioSubscription?.cancel(); _audioSubscription?.cancel();
_audioChannel?.sink.close(); _audioChannel?.sink.close();
_pcmPlayer.dispose();
super.dispose(); super.dispose();
} }
} }
@ -449,7 +484,8 @@ class _CircleButton extends StatelessWidget {
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
label, label,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 11), style:
const TextStyle(color: AppColors.textSecondary, fontSize: 11),
), ),
], ],
); );

View File

@ -23,12 +23,14 @@ class AuthUser {
final String email; final String email;
final String name; final String name;
final List<String> roles; final List<String> roles;
final String? tenantId;
const AuthUser({ const AuthUser({
required this.id, required this.id,
required this.email, required this.email,
required this.name, required this.name,
required this.roles, required this.roles,
this.tenantId,
}); });
factory AuthUser.fromJson(Map<String, dynamic> json) { factory AuthUser.fromJson(Map<String, dynamic> json) {
@ -37,6 +39,7 @@ class AuthUser {
email: json['email'] as String, email: json['email'] as String,
name: json['name'] as String, name: json['name'] as String,
roles: (json['roles'] as List).cast<String>(), roles: (json['roles'] as List).cast<String>(),
tenantId: json['tenantId'] as String?,
); );
} }
} }

View File

@ -1,9 +1,12 @@
import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/api_endpoints.dart';
import '../../../../core/config/app_config.dart'; import '../../../../core/config/app_config.dart';
import '../../../notifications/presentation/providers/notification_providers.dart';
import '../models/auth_response.dart'; import '../models/auth_response.dart';
import 'tenant_provider.dart';
const _keyAccessToken = 'access_token'; const _keyAccessToken = 'access_token';
const _keyRefreshToken = 'refresh_token'; const _keyRefreshToken = 'refresh_token';
@ -52,6 +55,7 @@ class AuthState {
class AuthNotifier extends StateNotifier<AuthState> { class AuthNotifier extends StateNotifier<AuthState> {
final Ref _ref; final Ref _ref;
StreamSubscription? _notificationSubscription;
AuthNotifier(this._ref) : super(const AuthState()); AuthNotifier(this._ref) : super(const AuthState());
@ -71,7 +75,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
AuthResponse.fromJson(response.data as Map<String, dynamic>); AuthResponse.fromJson(response.data as Map<String, dynamic>);
final storage = _ref.read(secureStorageProvider); final storage = _ref.read(secureStorageProvider);
await storage.write(key: _keyAccessToken, value: authResponse.accessToken); await storage.write(
key: _keyAccessToken, value: authResponse.accessToken);
await storage.write( await storage.write(
key: _keyRefreshToken, value: authResponse.refreshToken); key: _keyRefreshToken, value: authResponse.refreshToken);
@ -82,6 +87,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
); );
_ref.invalidate(accessTokenProvider); _ref.invalidate(accessTokenProvider);
// Set tenant context for all subsequent API calls
if (authResponse.user.tenantId != null) {
_ref.read(currentTenantIdProvider.notifier).state =
authResponse.user.tenantId;
}
// Connect notification service after login
_connectNotifications(authResponse.user.tenantId);
return true; return true;
} on DioException catch (e) { } on DioException catch (e) {
final message = final message =
@ -101,6 +116,14 @@ class AuthNotifier extends StateNotifier<AuthState> {
} }
Future<void> logout() async { Future<void> logout() async {
// Disconnect notification service
_notificationSubscription?.cancel();
_notificationSubscription = null;
_ref.read(notificationServiceProvider).disconnect();
// Clear tenant context
_ref.read(currentTenantIdProvider.notifier).state = null;
final storage = _ref.read(secureStorageProvider); final storage = _ref.read(secureStorageProvider);
await storage.delete(key: _keyAccessToken); await storage.delete(key: _keyAccessToken);
await storage.delete(key: _keyRefreshToken); await storage.delete(key: _keyRefreshToken);
@ -135,4 +158,22 @@ class AuthNotifier extends StateNotifier<AuthState> {
return false; return false;
} }
} }
void _connectNotifications(String? tenantId) {
if (tenantId == null) return;
try {
final notificationService = _ref.read(notificationServiceProvider);
final notificationList = _ref.read(notificationListProvider.notifier);
notificationService.connect(tenantId);
// Forward incoming notifications to the list
_notificationSubscription =
notificationService.notifications.listen((notification) {
notificationList.add(notification);
});
} catch (_) {
// Non-critical app works without push notifications
}
}
} }

View File

@ -0,0 +1,48 @@
/// Represents a push notification received from the backend.
class AppNotification {
final String id;
final String type; // alert, approval, task_complete, message
final String title;
final String body;
final String? actionRoute; // deep link path, e.g. '/alerts'
final DateTime receivedAt;
final bool isRead;
const AppNotification({
required this.id,
required this.type,
required this.title,
required this.body,
this.actionRoute,
required this.receivedAt,
this.isRead = false,
});
AppNotification copyWith({bool? isRead}) {
return AppNotification(
id: id,
type: type,
title: title,
body: body,
actionRoute: actionRoute,
receivedAt: receivedAt,
isRead: isRead ?? this.isRead,
);
}
factory AppNotification.fromJson(Map<String, dynamic> json) {
return AppNotification(
id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(),
type: json['type'] as String? ?? 'message',
title: json['title'] as String? ?? json['content'] as String? ?? '',
body: json['body'] as String? ?? json['content'] as String? ?? '',
actionRoute: json['actionRoute'] as String?,
receivedAt: json['receivedAt'] != null
? DateTime.parse(json['receivedAt'] as String)
: (json['sentAt'] != null
? DateTime.parse(json['sentAt'] as String)
: DateTime.now()),
isRead: json['isRead'] as bool? ?? false,
);
}
}

View File

@ -0,0 +1,57 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/config/app_config.dart';
import '../../../../core/services/notification_service.dart';
import '../../domain/entities/app_notification.dart';
/// Global local notifications plugin instance (initialized in main.dart).
final localNotificationsPluginProvider =
Provider<FlutterLocalNotificationsPlugin>((ref) {
return FlutterLocalNotificationsPlugin();
});
/// Notification service singleton.
final notificationServiceProvider = Provider<NotificationService>((ref) {
final config = ref.watch(appConfigProvider);
final plugin = ref.watch(localNotificationsPluginProvider);
return NotificationService(
baseUrl: config.apiBaseUrl,
localNotifications: plugin,
);
});
/// Accumulated notification list.
class NotificationListNotifier extends StateNotifier<List<AppNotification>> {
NotificationListNotifier() : super([]);
void add(AppNotification notification) {
state = [notification, ...state];
}
void markRead(String id) {
state = state.map((n) {
if (n.id == id) return n.copyWith(isRead: true);
return n;
}).toList();
}
void markAllRead() {
state = state.map((n) => n.copyWith(isRead: true)).toList();
}
void clear() {
state = [];
}
}
final notificationListProvider =
StateNotifierProvider<NotificationListNotifier, List<AppNotification>>(
(ref) {
return NotificationListNotifier();
});
/// Unread notification count.
final unreadNotificationCountProvider = Provider<int>((ref) {
final notifications = ref.watch(notificationListProvider);
return notifications.where((n) => !n.isRead).length;
});

View File

@ -0,0 +1,40 @@
import 'package:dio/dio.dart';
import '../../../../core/config/api_endpoints.dart';
/// Remote datasource for account settings against the IT0 backend API.
class SettingsRemoteDatasource {
final Dio _dio;
SettingsRemoteDatasource(this._dio);
/// Fetches the current user's account info (displayName, email).
Future<Map<String, dynamic>> getAccount() async {
final response = await _dio.get(ApiEndpoints.adminAccount);
return response.data as Map<String, dynamic>;
}
/// Updates the current user's display name.
Future<Map<String, dynamic>> updateAccount(String displayName) async {
final response = await _dio.put(
ApiEndpoints.adminAccount,
data: {'displayName': displayName},
);
return response.data as Map<String, dynamic>;
}
/// Changes the current user's password.
/// Returns `{success: true}` or `{success: false, message: ...}`.
Future<Map<String, dynamic>> changePassword({
required String currentPassword,
required String newPassword,
}) async {
final response = await _dio.put(
ApiEndpoints.adminAccountPassword,
data: {
'currentPassword': currentPassword,
'newPassword': newPassword,
},
);
return response.data as Map<String, dynamic>;
}
}

View File

@ -2,67 +2,312 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../auth/data/providers/auth_provider.dart'; import '../../../auth/data/providers/auth_provider.dart';
import '../providers/settings_providers.dart';
class SettingsPage extends ConsumerWidget { class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(accountProfileProvider.notifier).loadProfile();
});
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final profile = ref.watch(accountProfileProvider);
final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Settings')), appBar: AppBar(title: const Text('设置')),
body: ListView( body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [ children: [
const ListTile( // --- Profile Section ---
leading: Icon(Icons.person), _SectionHeader(title: '个人信息'),
title: Text('Profile'), ListTile(
trailing: Icon(Icons.chevron_right), leading: CircleAvatar(
backgroundColor: theme.colorScheme.primary,
child: Text(
profile.displayName.isNotEmpty
? profile.displayName[0].toUpperCase()
: '?',
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
), ),
const ListTile(
leading: Icon(Icons.business),
title: Text('Tenant'),
trailing: Icon(Icons.chevron_right),
), ),
const ListTile( title: Text(
leading: Icon(Icons.notifications), profile.displayName.isNotEmpty ? profile.displayName : '加载中...',
title: Text('Notifications'),
trailing: Icon(Icons.chevron_right),
), ),
const ListTile( subtitle: Text(profile.email),
leading: Icon(Icons.color_lens), trailing: IconButton(
title: Text('Theme'), icon: const Icon(Icons.edit),
trailing: Icon(Icons.chevron_right), onPressed: () => _showEditNameDialog(profile.displayName),
),
),
ListTile(
leading: const Icon(Icons.lock_outline),
title: const Text('修改密码'),
trailing: const Icon(Icons.chevron_right),
onTap: _showChangePasswordSheet,
), ),
const Divider(), const Divider(),
// --- Appearance Section ---
_SectionHeader(title: '外观'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.dark,
label: Text('深色'),
icon: Icon(Icons.dark_mode)),
ButtonSegment(
value: ThemeMode.light,
label: Text('浅色'),
icon: Icon(Icons.light_mode)),
ButtonSegment(
value: ThemeMode.system,
label: Text('跟随系统'),
icon: Icon(Icons.settings_brightness)),
],
selected: {settings.themeMode},
onSelectionChanged: (modes) {
ref.read(settingsProvider.notifier).setThemeMode(modes.first);
},
),
),
const Divider(),
// --- Notifications Section ---
_SectionHeader(title: '通知'),
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: const Text('推送通知'),
value: settings.notificationsEnabled,
onChanged: (v) {
ref.read(settingsProvider.notifier).setNotificationsEnabled(v);
},
),
SwitchListTile(
secondary: const Icon(Icons.volume_up_outlined),
title: const Text('提示音'),
value: settings.soundEnabled,
onChanged: settings.notificationsEnabled
? (v) => ref.read(settingsProvider.notifier).setSoundEnabled(v)
: null,
),
SwitchListTile(
secondary: const Icon(Icons.vibration),
title: const Text('震动反馈'),
value: settings.hapticFeedback,
onChanged: settings.notificationsEnabled
? (v) =>
ref.read(settingsProvider.notifier).setHapticFeedback(v)
: null,
),
const Divider(),
// --- About Section ---
_SectionHeader(title: '关于'),
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('应用版本'),
subtitle: Text('iAgent v1.0.0'),
),
if (settings.selectedTenantName != null)
ListTile(
leading: const Icon(Icons.business),
title: const Text('租户'),
subtitle: Text(settings.selectedTenantName!),
),
const Divider(),
// --- Logout ---
ListTile( ListTile(
leading: const Icon(Icons.logout, color: Colors.red), leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Logout', style: TextStyle(color: Colors.red)), title:
onTap: () async { const Text('退出登录', style: TextStyle(color: Colors.red)),
onTap: () => _confirmLogout(),
),
const SizedBox(height: 32),
],
),
);
}
void _showEditNameDialog(String currentName) {
final controller = TextEditingController(text: currentName);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改显示名称'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: '显示名称',
hintText: '输入新的显示名称',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () async {
final name = controller.text.trim();
if (name.isEmpty) return;
Navigator.of(ctx).pop();
final success = await ref
.read(accountProfileProvider.notifier)
.updateDisplayName(name);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? '名称已更新' : '更新失败'),
),
);
}
},
child: const Text('保存'),
),
],
),
);
}
void _showChangePasswordSheet() {
final currentCtrl = TextEditingController();
final newCtrl = TextEditingController();
final confirmCtrl = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.fromLTRB(
24,
24,
24,
MediaQuery.of(ctx).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('修改密码', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
TextField(
controller: currentCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: '当前密码'),
),
const SizedBox(height: 12),
TextField(
controller: newCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: '新密码'),
),
const SizedBox(height: 12),
TextField(
controller: confirmCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: '确认新密码'),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () async {
if (newCtrl.text != confirmCtrl.text) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('两次输入的密码不一致')),
);
return;
}
if (newCtrl.text.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('新密码至少6个字符')),
);
return;
}
Navigator.of(ctx).pop();
final result = await ref
.read(accountProfileProvider.notifier)
.changePassword(
currentPassword: currentCtrl.text,
newPassword: newCtrl.text,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.success
? '密码已修改'
: result.message ?? '修改失败',
),
),
);
}
},
child: const Text('确认修改'),
),
],
),
),
);
}
void _confirmLogout() async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Logout'), title: const Text('退出登录'),
content: const Text('Are you sure you want to logout?'), content: const Text('确定要退出登录吗?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(false), onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'), child: const Text('取消'),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Logout'), child: const Text('退出'),
), ),
], ],
), ),
); );
if (confirmed == true) { if (confirmed == true) {
await ref.read(authStateProvider.notifier).logout(); await ref.read(authStateProvider.notifier).logout();
if (context.mounted) { if (mounted) {
context.go('/login'); context.go('/login');
} }
} }
}, }
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
), ),
],
), ),
); );
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/network/dio_client.dart';
import '../../data/datasources/settings_datasource.dart'; import '../../data/datasources/settings_datasource.dart';
import '../../data/datasources/settings_remote_datasource.dart';
import '../../data/repositories/settings_repository_impl.dart'; import '../../data/repositories/settings_repository_impl.dart';
import '../../domain/entities/app_settings.dart'; import '../../domain/entities/app_settings.dart';
import '../../domain/repositories/settings_repository.dart'; import '../../domain/repositories/settings_repository.dart';
@ -27,6 +29,44 @@ final settingsRepositoryProvider = Provider<SettingsRepository?>((ref) {
return SettingsRepositoryImpl(datasource); return SettingsRepositoryImpl(datasource);
}); });
final settingsRemoteDatasourceProvider =
Provider<SettingsRemoteDatasource>((ref) {
final dio = ref.watch(dioClientProvider);
return SettingsRemoteDatasource(dio);
});
// ---------------------------------------------------------------------------
// Account profile state
// ---------------------------------------------------------------------------
class AccountProfile {
final String displayName;
final String email;
final bool isLoading;
final String? error;
const AccountProfile({
this.displayName = '',
this.email = '',
this.isLoading = false,
this.error,
});
AccountProfile copyWith({
String? displayName,
String? email,
bool? isLoading,
String? error,
}) {
return AccountProfile(
displayName: displayName ?? this.displayName,
email: email ?? this.email,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Settings state notifier // Settings state notifier
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -89,6 +129,62 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
} }
} }
// ---------------------------------------------------------------------------
// Account profile notifier
// ---------------------------------------------------------------------------
class AccountProfileNotifier extends StateNotifier<AccountProfile> {
final SettingsRemoteDatasource _remote;
AccountProfileNotifier(this._remote) : super(const AccountProfile());
Future<void> loadProfile() async {
state = state.copyWith(isLoading: true, error: null);
try {
final data = await _remote.getAccount();
state = state.copyWith(
displayName: data['displayName'] as String? ?? '',
email: data['email'] as String? ?? '',
isLoading: false,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<bool> updateDisplayName(String name) async {
state = state.copyWith(isLoading: true, error: null);
try {
final data = await _remote.updateAccount(name);
state = state.copyWith(
displayName: data['displayName'] as String? ?? name,
isLoading: false,
);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
return false;
}
}
Future<({bool success, String? message})> changePassword({
required String currentPassword,
required String newPassword,
}) async {
try {
final data = await _remote.changePassword(
currentPassword: currentPassword,
newPassword: newPassword,
);
final success = data['success'] as bool? ?? false;
final message = data['message'] as String?;
return (success: success, message: message);
} catch (e) {
return (success: false, message: e.toString());
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main providers // Main providers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -99,6 +195,12 @@ final settingsProvider =
return SettingsNotifier(repository); return SettingsNotifier(repository);
}); });
final accountProfileProvider =
StateNotifierProvider<AccountProfileNotifier, AccountProfile>((ref) {
final remote = ref.watch(settingsRemoteDatasourceProvider);
return AccountProfileNotifier(remote);
});
final themeModeProvider = Provider<ThemeMode>((ref) { final themeModeProvider = Provider<ThemeMode>((ref) {
return ref.watch(settingsProvider).themeMode; return ref.watch(settingsProvider).themeMode;
}); });

View File

@ -1,15 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'app.dart'; import 'app.dart';
import 'features/notifications/presentation/providers/notification_providers.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter(); await Hive.initFlutter();
// Initialize local notifications
final localNotifications = FlutterLocalNotificationsPlugin();
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
const initSettings = InitializationSettings(android: androidInit);
await localNotifications.initialize(initSettings);
runApp( runApp(
const ProviderScope( ProviderScope(
child: IT0App(), overrides: [
localNotificationsPluginProvider.overrideWithValue(localNotifications),
],
child: const IT0App(),
), ),
); );
} }

View File

@ -201,6 +201,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@ -278,6 +286,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.dev"
source: hosted
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_markdown: flutter_markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@ -342,6 +374,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
flutter_sound:
dependency: "direct main"
description:
name: flutter_sound
sha256: "77f0252a2f08449d621f68b8fd617c3b391e7f862eaa94bb32f53cc2dc3bbe85"
url: "https://pub.dev"
source: hosted
version: "9.30.0"
flutter_sound_platform_interface:
dependency: transitive
description:
name: flutter_sound_platform_interface
sha256: "5ffc858fd96c6fa277e3bb25eecc100849d75a8792b1f9674d1ba817aea9f861"
url: "https://pub.dev"
source: hosted
version: "9.30.0"
flutter_sound_web:
dependency: transitive
description:
name: flutter_sound_web
sha256: "3af46f45f44768c01c83ba260855c956d0963673664947926d942aa6fbf6f6fb"
url: "https://pub.dev"
source: hosted
version: "9.30.0"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@ -989,6 +1045,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
socket_io_client:
dependency: "direct main"
description:
name: socket_io_client
sha256: ef6c989e5eee8d04baf18482ec3d7699b91bc41e279794a99d8e3bef897b074a
url: "https://pub.dev"
source: hosted
version: "3.1.4"
socket_io_common:
dependency: transitive
description:
name: socket_io_common
sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:
@ -1053,6 +1125,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -1069,6 +1149,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
timing: timing:
dependency: transitive dependency: transitive
description: description:

View File

@ -41,6 +41,11 @@ dependencies:
record: ^6.0.0 record: ^6.0.0
flutter_tts: ^4.0.0 flutter_tts: ^4.0.0
sherpa_onnx: ^1.12.25 sherpa_onnx: ^1.12.25
flutter_sound: ^9.16.3
# Notifications
flutter_local_notifications: ^18.0.1
socket_io_client: ^3.0.2
# File paths # File paths
path_provider: ^2.1.0 path_provider: ^2.1.0

View File

@ -4,7 +4,7 @@ import { TenantContextService, TenantInfo } from '@it0/common';
@Injectable() @Injectable()
export class TenantContextMiddleware implements NestMiddleware { export class TenantContextMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) { use(req: any, res: any, next: () => void) {
const tenantId = req.headers?.['x-tenant-id'] as string; let tenantId = req.headers?.['x-tenant-id'] as string;
// Decode JWT to populate req.user for RolesGuard // Decode JWT to populate req.user for RolesGuard
const authHeader = req.headers?.['authorization'] as string; const authHeader = req.headers?.['authorization'] as string;
@ -20,6 +20,11 @@ export class TenantContextMiddleware implements NestMiddleware {
tenantId: payload.tenantId, tenantId: payload.tenantId,
roles: payload.roles || [], roles: payload.roles || [],
}; };
// Fall back to JWT tenantId if header is missing
if (!tenantId && payload.tenantId) {
tenantId = payload.tenantId;
}
} catch { } catch {
// Ignore decode errors - JWT validation is handled by Kong // Ignore decode errors - JWT validation is handled by Kong
} }