diff --git a/it0_app/android/app/src/main/AndroidManifest.xml b/it0_app/android/app/src/main/AndroidManifest.xml index fab3e17..60f9981 100644 --- a/it0_app/android/app/src/main/AndroidManifest.xml +++ b/it0_app/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + 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 feed(Uint8List pcmData) async { + if (!_initialized || _player == null) return; + // ignore: deprecated_member_use + await _player!.feedUint8FromStream(pcmData); + } + + /// Toggle speaker mode (earpiece vs loudspeaker). + Future 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 dispose() async { + if (_player != null) { + try { + await _player!.stopPlayer(); + } catch (_) {} + await _player!.closePlayer(); + _player = null; + } + _initialized = false; + } +} diff --git a/it0_app/lib/core/config/api_endpoints.dart b/it0_app/lib/core/config/api_endpoints.dart index 3fd8bca..d439f22 100644 --- a/it0_app/lib/core/config/api_endpoints.dart +++ b/it0_app/lib/core/config/api_endpoints.dart @@ -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'; } diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart index db24be0..3322dd9 100644 --- a/it0_app/lib/core/router/app_router.dart +++ b/it0_app/lib/core/router/app_router.dart @@ -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((ref) { return GoRouter( @@ -65,24 +66,41 @@ final routerProvider = Provider((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]); }, ), diff --git a/it0_app/lib/core/services/notification_service.dart b/it0_app/lib/core/services/notification_service.dart new file mode 100644 index 0000000..67cf19a --- /dev/null +++ b/it0_app/lib/core/services/notification_service.dart @@ -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.broadcast(); + int _notificationId = 0; + + NotificationService({ + required this.baseUrl, + this.accessToken, + required FlutterLocalNotificationsPlugin localNotifications, + }) : _localNotifications = localNotifications; + + /// Stream of incoming notifications. + Stream 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) { + 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 _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(); + } +} diff --git a/it0_app/lib/core/theme/app_theme.dart b/it0_app/lib/core/theme/app_theme.dart index 85f15ba..2281932 100644 --- a/it0_app/lib/core/theme/app_theme.dart +++ b/it0_app/lib/core/theme/app_theme.dart @@ -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)), + ), + ), + ); } diff --git a/it0_app/lib/features/agent_call/data/repositories/voice_repository_impl.dart b/it0_app/lib/features/agent_call/data/repositories/voice_repository_impl.dart index 4b44231..65a668f 100644 --- a/it0_app/lib/features/agent_call/data/repositories/voice_repository_impl.dart +++ b/it0_app/lib/features/agent_call/data/repositories/voice_repository_impl.dart @@ -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}', ); } diff --git a/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart b/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart index d45efe6..316cd52 100644 --- a/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart +++ b/it0_app/lib/features/agent_call/presentation/pages/agent_call_page.dart @@ -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 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>? _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 _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 // Call lifecycle // --------------------------------------------------------------------------- - /// Accept call: create voice session, get websocket_url, connect. + /// Accept call: create voice session, connect WebSocket, start mic + player. Future _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; _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 }); }); - // 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 } } - /// 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 _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) { + 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 _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 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 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 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 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 return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Decline FloatingActionButton( heroTag: 'decline', backgroundColor: AppColors.error, @@ -350,7 +382,6 @@ class _AgentCallPageState extends ConsumerState 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 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 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), ), ], ); diff --git a/it0_app/lib/features/auth/data/models/auth_response.dart b/it0_app/lib/features/auth/data/models/auth_response.dart index a7a8dc5..3b36e46 100644 --- a/it0_app/lib/features/auth/data/models/auth_response.dart +++ b/it0_app/lib/features/auth/data/models/auth_response.dart @@ -23,12 +23,14 @@ class AuthUser { final String email; final String name; final List roles; + final String? tenantId; const AuthUser({ required this.id, required this.email, required this.name, required this.roles, + this.tenantId, }); factory AuthUser.fromJson(Map json) { @@ -37,6 +39,7 @@ class AuthUser { email: json['email'] as String, name: json['name'] as String, roles: (json['roles'] as List).cast(), + tenantId: json['tenantId'] as String?, ); } } diff --git a/it0_app/lib/features/auth/data/providers/auth_provider.dart b/it0_app/lib/features/auth/data/providers/auth_provider.dart index ef28cf1..e61af19 100644 --- a/it0_app/lib/features/auth/data/providers/auth_provider.dart +++ b/it0_app/lib/features/auth/data/providers/auth_provider.dart @@ -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 { final Ref _ref; + StreamSubscription? _notificationSubscription; AuthNotifier(this._ref) : super(const AuthState()); @@ -71,7 +75,8 @@ class AuthNotifier extends StateNotifier { AuthResponse.fromJson(response.data as Map); 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 { ); _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 { } Future 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 { 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 + } + } } diff --git a/it0_app/lib/features/notifications/domain/entities/app_notification.dart b/it0_app/lib/features/notifications/domain/entities/app_notification.dart new file mode 100644 index 0000000..88f828c --- /dev/null +++ b/it0_app/lib/features/notifications/domain/entities/app_notification.dart @@ -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 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, + ); + } +} diff --git a/it0_app/lib/features/notifications/presentation/providers/notification_providers.dart b/it0_app/lib/features/notifications/presentation/providers/notification_providers.dart new file mode 100644 index 0000000..f29736c --- /dev/null +++ b/it0_app/lib/features/notifications/presentation/providers/notification_providers.dart @@ -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((ref) { + return FlutterLocalNotificationsPlugin(); +}); + +/// Notification service singleton. +final notificationServiceProvider = Provider((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> { + 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>( + (ref) { + return NotificationListNotifier(); +}); + +/// Unread notification count. +final unreadNotificationCountProvider = Provider((ref) { + final notifications = ref.watch(notificationListProvider); + return notifications.where((n) => !n.isRead).length; +}); diff --git a/it0_app/lib/features/settings/data/datasources/settings_remote_datasource.dart b/it0_app/lib/features/settings/data/datasources/settings_remote_datasource.dart new file mode 100644 index 0000000..bb744de --- /dev/null +++ b/it0_app/lib/features/settings/data/datasources/settings_remote_datasource.dart @@ -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> getAccount() async { + final response = await _dio.get(ApiEndpoints.adminAccount); + return response.data as Map; + } + + /// Updates the current user's display name. + Future> updateAccount(String displayName) async { + final response = await _dio.put( + ApiEndpoints.adminAccount, + data: {'displayName': displayName}, + ); + return response.data as Map; + } + + /// Changes the current user's password. + /// Returns `{success: true}` or `{success: false, message: ...}`. + Future> 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; + } +} diff --git a/it0_app/lib/features/settings/presentation/pages/settings_page.dart b/it0_app/lib/features/settings/presentation/pages/settings_page.dart index 2535c91..4298384 100644 --- a/it0_app/lib/features/settings/presentation/pages/settings_page.dart +++ b/it0_app/lib/features/settings/presentation/pages/settings_page.dart @@ -2,68 +2,313 @@ 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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + @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), + ), + ), + title: Text( + profile.displayName.isNotEmpty ? profile.displayName : '加载中...', + ), + subtitle: Text(profile.email), + trailing: IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _showEditNameDialog(profile.displayName), + ), ), - 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), - ), - const ListTile( - leading: Icon(Icons.color_lens), - title: Text('Theme'), - trailing: Icon(Icons.chevron_right), + 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( + 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 { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Logout'), - content: const Text('Are you sure you want to logout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Logout'), - ), - ], - ), - ); - if (confirmed == true) { - await ref.read(authStateProvider.notifier).logout(); - if (context.mounted) { - context.go('/login'); - } + 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( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('退出登录'), + content: const Text('确定要退出登录吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('退出'), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(authStateProvider.notifier).logout(); + 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, + ), + ), + ); + } } diff --git a/it0_app/lib/features/settings/presentation/providers/settings_providers.dart b/it0_app/lib/features/settings/presentation/providers/settings_providers.dart index 010edc9..7becd38 100644 --- a/it0_app/lib/features/settings/presentation/providers/settings_providers.dart +++ b/it0_app/lib/features/settings/presentation/providers/settings_providers.dart @@ -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((ref) { return SettingsRepositoryImpl(datasource); }); +final settingsRemoteDatasourceProvider = + Provider((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 { } } +// --------------------------------------------------------------------------- +// Account profile notifier +// --------------------------------------------------------------------------- + +class AccountProfileNotifier extends StateNotifier { + final SettingsRemoteDatasource _remote; + + AccountProfileNotifier(this._remote) : super(const AccountProfile()); + + Future 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 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((ref) { + final remote = ref.watch(settingsRemoteDatasourceProvider); + return AccountProfileNotifier(remote); +}); + final themeModeProvider = Provider((ref) { return ref.watch(settingsProvider).themeMode; }); diff --git a/it0_app/lib/main.dart b/it0_app/lib/main.dart index 3edd864..77d4aae 100644 --- a/it0_app/lib/main.dart +++ b/it0_app/lib/main.dart @@ -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(), ), ); } diff --git a/it0_app/pubspec.lock b/it0_app/pubspec.lock index 748765b..d182d48 100644 --- a/it0_app/pubspec.lock +++ b/it0_app/pubspec.lock @@ -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: diff --git a/it0_app/pubspec.yaml b/it0_app/pubspec.yaml index 2e3ccb4..31560ad 100644 --- a/it0_app/pubspec.yaml +++ b/it0_app/pubspec.yaml @@ -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 diff --git a/packages/shared/database/src/tenant-context.middleware.ts b/packages/shared/database/src/tenant-context.middleware.ts index 4aa0a4e..798b1f2 100644 --- a/packages/shared/database/src/tenant-context.middleware.ts +++ b/packages/shared/database/src/tenant-context.middleware.ts @@ -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 }