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:
parent
6a293d734b
commit
092a561867
|
|
@ -1,6 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="iAgent"
|
||||
|
|
|
|||
|
|
@ -15,11 +15,21 @@ import io.flutter.embedding.engine.FlutterEngine;
|
|||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
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 {
|
||||
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
||||
} catch (Exception 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 {
|
||||
flutterEngine.getPlugins().add(new com.eyedeadevelopment.fluttertts.FlutterTtsPlugin());
|
||||
} catch (Exception e) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/settings/presentation/providers/settings_providers.dart';
|
||||
|
||||
class IT0App extends ConsumerWidget {
|
||||
const IT0App({super.key});
|
||||
|
|
@ -9,10 +10,13 @@ class IT0App extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'iAgent',
|
||||
theme: AppTheme.darkTheme,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: themeMode,
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,13 @@ class ApiEndpoints {
|
|||
// Voice
|
||||
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
|
||||
static const String wsTerminal = '/ws/terminal';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../../features/servers/presentation/pages/servers_page.dart';
|
|||
import '../../features/alerts/presentation/pages/alerts_page.dart';
|
||||
import '../../features/settings/presentation/pages/settings_page.dart';
|
||||
import '../../features/terminal/presentation/pages/terminal_page.dart';
|
||||
import '../../features/notifications/presentation/providers/notification_providers.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
|
|
@ -65,24 +66,41 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
class ScaffoldWithNav extends StatelessWidget {
|
||||
class ScaffoldWithNav extends ConsumerWidget {
|
||||
final Widget child;
|
||||
const ScaffoldWithNav({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unreadCount = ref.watch(unreadNotificationCountProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'),
|
||||
NavigationDestination(icon: Icon(Icons.chat), label: 'Chat'),
|
||||
NavigationDestination(icon: Icon(Icons.task), label: 'Tasks'),
|
||||
NavigationDestination(icon: Icon(Icons.notifications), label: 'Alerts'),
|
||||
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.dashboard), label: '仪表盘'),
|
||||
const NavigationDestination(icon: Icon(Icons.chat), label: '对话'),
|
||||
const NavigationDestination(icon: Icon(Icons.task), label: '任务'),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: unreadCount > 0,
|
||||
label: Text('$unreadCount'),
|
||||
child: const Icon(Icons.notifications),
|
||||
),
|
||||
label: '告警',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings), label: '设置'),
|
||||
],
|
||||
onDestinationSelected: (index) {
|
||||
final routes = ['/dashboard', '/chat', '/tasks', '/alerts', '/settings'];
|
||||
final routes = [
|
||||
'/dashboard',
|
||||
'/chat',
|
||||
'/tasks',
|
||||
'/alerts',
|
||||
'/settings'
|
||||
];
|
||||
GoRouter.of(context).go(routes[index]);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,16 @@ class VoiceRepositoryImpl implements VoiceRepository {
|
|||
// If the backend didn't provide a WebSocket URL, construct one
|
||||
if (session.websocketUrl == null) {
|
||||
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}',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,15 @@ import 'dart:math';
|
|||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:record/record.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/app_config.dart';
|
||||
import '../../../../core/network/dio_client.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.
|
||||
enum _CallPhase { ringing, connecting, active, ended }
|
||||
|
||||
|
|
@ -27,28 +26,35 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
with SingleTickerProviderStateMixin {
|
||||
_CallPhase _phase = _CallPhase.ringing;
|
||||
String? _sessionId;
|
||||
String? _wsUrl;
|
||||
WebSocketChannel? _audioChannel;
|
||||
StreamSubscription? _audioSubscription;
|
||||
|
||||
// Audio capture
|
||||
late final AudioRecorder _recorder;
|
||||
final SpeechEnhancer _enhancer = SpeechEnhancer();
|
||||
StreamSubscription<List<int>>? _micSubscription;
|
||||
|
||||
// Audio playback
|
||||
final PcmPlayer _pcmPlayer = PcmPlayer();
|
||||
|
||||
// Call duration
|
||||
final Stopwatch _stopwatch = Stopwatch();
|
||||
Timer? _durationTimer;
|
||||
String _durationLabel = '00:00';
|
||||
|
||||
// Waveform placeholder
|
||||
// Waveform
|
||||
late AnimationController _waveController;
|
||||
final List<double> _waveHeights = List.generate(20, (_) => 0.3);
|
||||
final _random = Random();
|
||||
Timer? _waveTimer;
|
||||
|
||||
// Mute state
|
||||
// Mute / speaker state
|
||||
bool _isMuted = false;
|
||||
bool _isSpeakerOn = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recorder = AudioRecorder();
|
||||
_enhancer.init();
|
||||
_waveController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
|
|
@ -59,40 +65,47 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
// 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 {
|
||||
setState(() => _phase = _CallPhase.connecting);
|
||||
|
||||
try {
|
||||
final dio = ref.read(dioClientProvider);
|
||||
final config = ref.read(appConfigProvider);
|
||||
final response = await dio.post('${ApiEndpoints.voice}/sessions');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
|
||||
_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) {
|
||||
// Construct the WS URL from config if the server didn't provide one
|
||||
final config = ref.read(appConfigProvider);
|
||||
_wsUrl ??= '${config.wsBaseUrl}/ws/voice/$_sessionId';
|
||||
// Build WebSocket URL — prefer backend-returned path
|
||||
String wsUrl;
|
||||
final backendWsUrl =
|
||||
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
|
||||
_audioChannel = WebSocketChannel.connect(Uri.parse(_wsUrl!));
|
||||
// Connect WebSocket
|
||||
_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(
|
||||
(data) {
|
||||
_onAudioReceived(data);
|
||||
},
|
||||
(data) => _onAudioReceived(data),
|
||||
onDone: () => _onCallEnded(),
|
||||
onError: (_) => _onCallEnded(),
|
||||
);
|
||||
|
||||
// Start sending simulated mic audio (real implementation would use
|
||||
// platform microphone via record/audio_session packages and stream
|
||||
// PCM 16kHz chunks).
|
||||
_startMicCapture();
|
||||
// Initialize audio playback
|
||||
await _pcmPlayer.init();
|
||||
|
||||
// Start mic capture
|
||||
await _startMicCapture();
|
||||
|
||||
// Start duration timer
|
||||
_stopwatch.start();
|
||||
|
|
@ -105,23 +118,14 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
});
|
||||
});
|
||||
|
||||
// Animate waveform
|
||||
_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);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to start call: $e'),
|
||||
content: Text('通话连接失败: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
|
|
@ -130,66 +134,105 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
}
|
||||
}
|
||||
|
||||
/// Simulate microphone capture – in production this would use the `record`
|
||||
/// package or platform channels to capture raw PCM 16kHz audio and stream it
|
||||
/// over the WebSocket.
|
||||
void _startMicCapture() {
|
||||
// Placeholder: send a small silent PCM buffer every 100ms to keep the
|
||||
// WebSocket alive. A real implementation would hook into the mic stream.
|
||||
Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
||||
if (_phase != _CallPhase.active || _isMuted) {
|
||||
if (_phase == _CallPhase.ended) timer.cancel();
|
||||
return;
|
||||
}
|
||||
/// Start real microphone capture: record PCM 16kHz mono, denoise, send over WS.
|
||||
Future<void> _startMicCapture() async {
|
||||
final hasPermission = await _recorder.hasPermission();
|
||||
if (!hasPermission) return;
|
||||
|
||||
final stream = await _recorder.startStream(const RecordConfig(
|
||||
encoder: AudioEncoder.pcm16bits,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
noiseSuppress: true,
|
||||
autoGain: true,
|
||||
));
|
||||
|
||||
_micSubscription = stream.listen((chunk) {
|
||||
if (_phase != _CallPhase.active || _isMuted) return;
|
||||
try {
|
||||
// Send 16kHz * 0.1s * 2 bytes = 3200 bytes of silence
|
||||
final silence = Uint8List(3200);
|
||||
_audioChannel?.sink.add(silence);
|
||||
} catch (_) {
|
||||
timer.cancel();
|
||||
}
|
||||
// Denoise each chunk with GTCRN
|
||||
final pcm = Uint8List.fromList(chunk);
|
||||
final denoised = _enhancer.enhance(pcm);
|
||||
_audioChannel?.sink.add(denoised);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle incoming audio from the agent side.
|
||||
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;
|
||||
|
||||
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(() {
|
||||
final rand = Random();
|
||||
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 {
|
||||
setState(() => _phase = _CallPhase.ended);
|
||||
_stopwatch.stop();
|
||||
_durationTimer?.cancel();
|
||||
_waveTimer?.cancel();
|
||||
_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 {
|
||||
await _audioChannel?.sink.close();
|
||||
} catch (_) {}
|
||||
|
||||
// Delete the voice session on the server
|
||||
// Delete voice session on the server
|
||||
if (_sessionId != null) {
|
||||
try {
|
||||
final dio = ref.read(dioClientProvider);
|
||||
await dio.delete('${ApiEndpoints.voice}/sessions/$_sessionId');
|
||||
} catch (_) {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Brief delay so the user sees "Call ended" before popping
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
|
@ -212,25 +255,20 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// Agent avatar / icon
|
||||
_buildAvatar(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Status text
|
||||
Text(
|
||||
_statusText,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
style:
|
||||
const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_subtitleText,
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 15),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary, fontSize: 15),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Duration
|
||||
if (_phase == _CallPhase.active)
|
||||
Text(
|
||||
_durationLabel,
|
||||
|
|
@ -241,15 +279,9 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
letterSpacing: 4,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Waveform visualization placeholder
|
||||
if (_phase == _CallPhase.active) _buildWaveform(),
|
||||
|
||||
const Spacer(flex: 3),
|
||||
|
||||
// Control buttons
|
||||
_buildControls(),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
|
|
@ -261,24 +293,24 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
String get _statusText {
|
||||
switch (_phase) {
|
||||
case _CallPhase.ringing:
|
||||
return 'iAgent Calling';
|
||||
return 'iAgent 语音通话';
|
||||
case _CallPhase.connecting:
|
||||
return 'Connecting...';
|
||||
return '连接中...';
|
||||
case _CallPhase.active:
|
||||
return 'iAgent';
|
||||
case _CallPhase.ended:
|
||||
return 'Call Ended';
|
||||
return '通话结束';
|
||||
}
|
||||
}
|
||||
|
||||
String get _subtitleText {
|
||||
switch (_phase) {
|
||||
case _CallPhase.ringing:
|
||||
return 'Incoming voice call';
|
||||
return '智能运维语音助手';
|
||||
case _CallPhase.connecting:
|
||||
return 'Setting up secure channel';
|
||||
return '正在建立安全连接';
|
||||
case _CallPhase.active:
|
||||
return 'Voice call in progress';
|
||||
return '语音通话中';
|
||||
case _CallPhase.ended:
|
||||
return _durationLabel;
|
||||
}
|
||||
|
|
@ -327,7 +359,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
height: 10 + _waveHeights[i] * 50,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
|
@ -342,7 +375,6 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Decline
|
||||
FloatingActionButton(
|
||||
heroTag: 'decline',
|
||||
backgroundColor: AppColors.error,
|
||||
|
|
@ -350,7 +382,6 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
child: const Icon(Icons.call_end, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 40),
|
||||
// Accept
|
||||
FloatingActionButton(
|
||||
heroTag: 'accept',
|
||||
backgroundColor: AppColors.success,
|
||||
|
|
@ -367,15 +398,13 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Mute
|
||||
_CircleButton(
|
||||
icon: _isMuted ? Icons.mic_off : Icons.mic,
|
||||
label: _isMuted ? 'Unmute' : 'Mute',
|
||||
label: _isMuted ? '取消静音' : '静音',
|
||||
isActive: _isMuted,
|
||||
onTap: () => setState(() => _isMuted = !_isMuted),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// End call
|
||||
FloatingActionButton(
|
||||
heroTag: 'end',
|
||||
backgroundColor: AppColors.error,
|
||||
|
|
@ -383,29 +412,35 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
|||
child: const Icon(Icons.call_end, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// Speaker
|
||||
_CircleButton(
|
||||
icon: _isSpeakerOn ? Icons.volume_up : Icons.volume_down,
|
||||
label: 'Speaker',
|
||||
label: '扬声器',
|
||||
isActive: _isSpeakerOn,
|
||||
onTap: () => setState(() => _isSpeakerOn = !_isSpeakerOn),
|
||||
onTap: () {
|
||||
setState(() => _isSpeakerOn = !_isSpeakerOn);
|
||||
_pcmPlayer.setSpeakerOn(_isSpeakerOn);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
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
|
||||
void dispose() {
|
||||
_durationTimer?.cancel();
|
||||
_waveTimer?.cancel();
|
||||
_waveController.dispose();
|
||||
_stopwatch.stop();
|
||||
_micSubscription?.cancel();
|
||||
_recorder.dispose();
|
||||
_enhancer.dispose();
|
||||
_audioSubscription?.cancel();
|
||||
_audioChannel?.sink.close();
|
||||
_pcmPlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -449,7 +484,8 @@ class _CircleButton extends StatelessWidget {
|
|||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 11),
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary, fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,12 +23,14 @@ class AuthUser {
|
|||
final String email;
|
||||
final String name;
|
||||
final List<String> roles;
|
||||
final String? tenantId;
|
||||
|
||||
const AuthUser({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.roles,
|
||||
this.tenantId,
|
||||
});
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -37,6 +39,7 @@ class AuthUser {
|
|||
email: json['email'] as String,
|
||||
name: json['name'] as String,
|
||||
roles: (json['roles'] as List).cast<String>(),
|
||||
tenantId: json['tenantId'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../../../../core/config/api_endpoints.dart';
|
||||
import '../../../../core/config/app_config.dart';
|
||||
import '../../../notifications/presentation/providers/notification_providers.dart';
|
||||
import '../models/auth_response.dart';
|
||||
import 'tenant_provider.dart';
|
||||
|
||||
const _keyAccessToken = 'access_token';
|
||||
const _keyRefreshToken = 'refresh_token';
|
||||
|
|
@ -52,6 +55,7 @@ class AuthState {
|
|||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final Ref _ref;
|
||||
StreamSubscription? _notificationSubscription;
|
||||
|
||||
AuthNotifier(this._ref) : super(const AuthState());
|
||||
|
||||
|
|
@ -71,7 +75,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
AuthResponse.fromJson(response.data as Map<String, dynamic>);
|
||||
|
||||
final storage = _ref.read(secureStorageProvider);
|
||||
await storage.write(key: _keyAccessToken, value: authResponse.accessToken);
|
||||
await storage.write(
|
||||
key: _keyAccessToken, value: authResponse.accessToken);
|
||||
await storage.write(
|
||||
key: _keyRefreshToken, value: authResponse.refreshToken);
|
||||
|
||||
|
|
@ -82,6 +87,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
);
|
||||
|
||||
_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;
|
||||
} on DioException catch (e) {
|
||||
final message =
|
||||
|
|
@ -101,6 +116,14 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
}
|
||||
|
||||
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);
|
||||
await storage.delete(key: _keyAccessToken);
|
||||
await storage.delete(key: _keyRefreshToken);
|
||||
|
|
@ -135,4 +158,22 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,67 +2,312 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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});
|
||||
|
||||
@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(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
appBar: AppBar(title: const Text('设置')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.person),
|
||||
title: Text('Profile'),
|
||||
trailing: Icon(Icons.chevron_right),
|
||||
// --- Profile Section ---
|
||||
_SectionHeader(title: '个人信息'),
|
||||
ListTile(
|
||||
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(
|
||||
leading: Icon(Icons.notifications),
|
||||
title: Text('Notifications'),
|
||||
trailing: Icon(Icons.chevron_right),
|
||||
title: Text(
|
||||
profile.displayName.isNotEmpty ? profile.displayName : '加载中...',
|
||||
),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.color_lens),
|
||||
title: Text('Theme'),
|
||||
trailing: Icon(Icons.chevron_right),
|
||||
subtitle: Text(profile.email),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _showEditNameDialog(profile.displayName),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lock_outline),
|
||||
title: const Text('修改密码'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _showChangePasswordSheet,
|
||||
),
|
||||
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(
|
||||
leading: const Icon(Icons.logout, color: Colors.red),
|
||||
title: const Text('Logout', style: TextStyle(color: Colors.red)),
|
||||
onTap: () async {
|
||||
title:
|
||||
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>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Logout'),
|
||||
content: const Text('Are you sure you want to logout?'),
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定要退出登录吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Logout'),
|
||||
child: const Text('退出'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(authStateProvider.notifier).logout();
|
||||
if (context.mounted) {
|
||||
if (mounted) {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../data/datasources/settings_datasource.dart';
|
||||
import '../../data/datasources/settings_remote_datasource.dart';
|
||||
import '../../data/repositories/settings_repository_impl.dart';
|
||||
import '../../domain/entities/app_settings.dart';
|
||||
import '../../domain/repositories/settings_repository.dart';
|
||||
|
|
@ -27,6 +29,44 @@ final settingsRepositoryProvider = Provider<SettingsRepository?>((ref) {
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -99,6 +195,12 @@ final settingsProvider =
|
|||
return SettingsNotifier(repository);
|
||||
});
|
||||
|
||||
final accountProfileProvider =
|
||||
StateNotifierProvider<AccountProfileNotifier, AccountProfile>((ref) {
|
||||
final remote = ref.watch(settingsRemoteDatasourceProvider);
|
||||
return AccountProfileNotifier(remote);
|
||||
});
|
||||
|
||||
final themeModeProvider = Provider<ThemeMode>((ref) {
|
||||
return ref.watch(settingsProvider).themeMode;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'app.dart';
|
||||
import 'features/notifications/presentation/providers/notification_providers.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
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(
|
||||
const ProviderScope(
|
||||
child: IT0App(),
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localNotificationsPluginProvider.overrideWithValue(localNotifications),
|
||||
],
|
||||
child: const IT0App(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -278,6 +286,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -342,6 +374,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -989,6 +1045,22 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1053,6 +1125,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1069,6 +1149,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ dependencies:
|
|||
record: ^6.0.0
|
||||
flutter_tts: ^4.0.0
|
||||
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
|
||||
path_provider: ^2.1.0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { TenantContextService, TenantInfo } from '@it0/common';
|
|||
@Injectable()
|
||||
export class TenantContextMiddleware implements NestMiddleware {
|
||||
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
|
||||
const authHeader = req.headers?.['authorization'] as string;
|
||||
|
|
@ -20,6 +20,11 @@ export class TenantContextMiddleware implements NestMiddleware {
|
|||
tenantId: payload.tenantId,
|
||||
roles: payload.roles || [],
|
||||
};
|
||||
|
||||
// Fall back to JWT tenantId if header is missing
|
||||
if (!tenantId && payload.tenantId) {
|
||||
tenantId = payload.tenantId;
|
||||
}
|
||||
} catch {
|
||||
// Ignore decode errors - JWT validation is handled by Kong
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue