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">
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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 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}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: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,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue