| .. | .. |
|---|
| 13 | 13 | import 'package:shared_preferences/shared_preferences.dart'; |
|---|
| 14 | 14 | |
|---|
| 15 | 15 | import '../models/message.dart'; |
|---|
| 16 | +import '../models/project.dart'; |
|---|
| 16 | 17 | import '../models/session.dart'; |
|---|
| 17 | 18 | // ignore: unused_import |
|---|
| 18 | 19 | import '../models/server_config.dart'; |
|---|
| .. | .. |
|---|
| 30 | 31 | import '../widgets/message_bubble.dart'; |
|---|
| 31 | 32 | import '../widgets/paywall_banner.dart'; |
|---|
| 32 | 33 | import '../widgets/session_drawer.dart'; |
|---|
| 34 | +import '../widgets/new_session_sheet.dart'; |
|---|
| 33 | 35 | import '../widgets/status_dot.dart'; |
|---|
| 34 | 36 | import '../widgets/toast_overlay.dart'; |
|---|
| 35 | 37 | import '../widgets/typing_indicator.dart'; |
|---|
| .. | .. |
|---|
| 100 | 102 | final savedUnreads = prefs.getString('unreadCounts'); |
|---|
| 101 | 103 | if (savedUnreads != null && mounted) { |
|---|
| 102 | 104 | try { |
|---|
| 103 | | - final map = (jsonDecode(savedUnreads) as Map<String, dynamic>) |
|---|
| 104 | | - .map((k, v) => MapEntry(k, v as int)); |
|---|
| 105 | + final map = (jsonDecode(savedUnreads) as Map<String, dynamic>).map( |
|---|
| 106 | + (k, v) => MapEntry(k, v as int), |
|---|
| 107 | + ); |
|---|
| 105 | 108 | ref.read(unreadCountsProvider.notifier).state = map; |
|---|
| 106 | 109 | } catch (_) {} |
|---|
| 107 | 110 | } |
|---|
| .. | .. |
|---|
| 221 | 224 | ref.read(wsStatusProvider.notifier).state = status; |
|---|
| 222 | 225 | if (status == ConnectionStatus.connected) { |
|---|
| 223 | 226 | ref.read(connectionDetailProvider.notifier).state = ''; |
|---|
| 224 | | - ref.read(connectedViaProvider.notifier).state = _ws?.connectedVia ?? ''; |
|---|
| 227 | + ref.read(connectedViaProvider.notifier).state = |
|---|
| 228 | + _ws?.connectedVia ?? ''; |
|---|
| 225 | 229 | } else { |
|---|
| 226 | 230 | ref.read(connectedViaProvider.notifier).state = ''; |
|---|
| 227 | 231 | } |
|---|
| .. | .. |
|---|
| 243 | 247 | Future.delayed(const Duration(milliseconds: 200), () { |
|---|
| 244 | 248 | if (!mounted) return; |
|---|
| 245 | 249 | final activeId = ref.read(activeSessionIdProvider); |
|---|
| 246 | | - _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null); |
|---|
| 250 | + _sendCommand( |
|---|
| 251 | + 'sync', |
|---|
| 252 | + activeId != null ? {'activeSessionId': activeId} : null, |
|---|
| 253 | + ); |
|---|
| 247 | 254 | _push?.onMqttConnected(); |
|---|
| 248 | 255 | }); |
|---|
| 249 | 256 | }; |
|---|
| .. | .. |
|---|
| 270 | 277 | _sendCommand('nav', {'key': key}); |
|---|
| 271 | 278 | }, |
|---|
| 272 | 279 | requestScreenshot: (sessionId) { |
|---|
| 273 | | - _sendCommand('screenshot', {'sessionId': sessionId ?? ref.read(activeSessionIdProvider)}); |
|---|
| 280 | + _sendCommand('screenshot', { |
|---|
| 281 | + 'sessionId': sessionId ?? ref.read(activeSessionIdProvider), |
|---|
| 282 | + }); |
|---|
| 274 | 283 | }, |
|---|
| 275 | 284 | ); |
|---|
| 276 | 285 | |
|---|
| .. | .. |
|---|
| 332 | 341 | switch (type) { |
|---|
| 333 | 342 | case 'sessions': |
|---|
| 334 | 343 | _handleSessions(msg); |
|---|
| 344 | + case 'projects': |
|---|
| 345 | + _handleProjects(msg); |
|---|
| 335 | 346 | case 'message': |
|---|
| 336 | 347 | case 'text': |
|---|
| 337 | 348 | _handleIncomingMessage(msg); |
|---|
| .. | .. |
|---|
| 340 | 351 | case 'image': |
|---|
| 341 | 352 | _handleIncomingImage(msg); |
|---|
| 342 | 353 | case 'typing': |
|---|
| 343 | | - final typing = msg['typing'] as bool? ?? msg['isTyping'] as bool? ?? msg['active'] as bool? ?? true; |
|---|
| 354 | + final typing = |
|---|
| 355 | + msg['typing'] as bool? ?? |
|---|
| 356 | + msg['isTyping'] as bool? ?? |
|---|
| 357 | + msg['active'] as bool? ?? |
|---|
| 358 | + true; |
|---|
| 344 | 359 | final typingSession = msg['sessionId'] as String?; |
|---|
| 345 | 360 | final activeId = ref.read(activeSessionIdProvider); |
|---|
| 346 | | - _chatLog('TYPING: session=${typingSession?.substring(0, 8)} active=${activeId?.substring(0, 8)} typing=$typing match=${typingSession == activeId}'); |
|---|
| 361 | + _chatLog( |
|---|
| 362 | + 'TYPING: session=${typingSession?.substring(0, 8)} active=${activeId?.substring(0, 8)} typing=$typing match=${typingSession == activeId}', |
|---|
| 363 | + ); |
|---|
| 347 | 364 | // Strict: only show typing for the ACTIVE session, ignore all others |
|---|
| 348 | 365 | if (activeId != null && typingSession == activeId) { |
|---|
| 349 | 366 | ref.read(isTypingProvider.notifier).state = typing; |
|---|
| .. | .. |
|---|
| 369 | 386 | case 'clear': |
|---|
| 370 | 387 | ref.read(messagesProvider.notifier).clearMessages(); |
|---|
| 371 | 388 | case 'session_switched': |
|---|
| 389 | + // The hub switched/created a session (e.g. from the launcher) — follow it |
|---|
| 390 | + // so tapping a project actually opens that session in the app. |
|---|
| 391 | + final switchedId = msg['sessionId'] as String?; |
|---|
| 392 | + if (switchedId != null && switchedId.isNotEmpty) { |
|---|
| 393 | + ref.read(activeSessionIdProvider.notifier).state = switchedId; |
|---|
| 394 | + ref.read(messagesProvider.notifier).switchSession(switchedId); |
|---|
| 395 | + SharedPreferences.getInstance().then( |
|---|
| 396 | + (p) => p.setString('activeSessionId', switchedId), |
|---|
| 397 | + ); |
|---|
| 398 | + } |
|---|
| 372 | 399 | _sendCommand('sessions'); |
|---|
| 373 | 400 | case 'session_renamed': |
|---|
| 374 | 401 | _sendCommand('sessions'); |
|---|
| .. | .. |
|---|
| 380 | 407 | final currentMessages = ref.read(messagesProvider); |
|---|
| 381 | 408 | final inCurrent = currentMessages.any((m) => m.id == messageId); |
|---|
| 382 | 409 | if (inCurrent) { |
|---|
| 383 | | - ref.read(messagesProvider.notifier).updateContent(messageId, content); |
|---|
| 410 | + ref |
|---|
| 411 | + .read(messagesProvider.notifier) |
|---|
| 412 | + .updateContent(messageId, content); |
|---|
| 384 | 413 | } else { |
|---|
| 385 | 414 | // Message is in a different session (user switched after recording). |
|---|
| 386 | 415 | // Load that session's messages from disk, update, and save back. |
|---|
| .. | .. |
|---|
| 404 | 433 | if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) { |
|---|
| 405 | 434 | _isCatchingUp = true; |
|---|
| 406 | 435 | final activeId = ref.read(activeSessionIdProvider); |
|---|
| 407 | | - final currentId = ref.read(messagesProvider.notifier).currentSessionId; |
|---|
| 436 | + final currentId = ref |
|---|
| 437 | + .read(messagesProvider.notifier) |
|---|
| 438 | + .currentSessionId; |
|---|
| 408 | 439 | final existing = ref.read(messagesProvider); |
|---|
| 409 | 440 | final existingContents = existing |
|---|
| 410 | 441 | .where((m) => m.role == MessageRole.assistant) |
|---|
| .. | .. |
|---|
| 418 | 449 | for (final m in catchUpMsgs) { |
|---|
| 419 | 450 | final map = m as Map<String, dynamic>; |
|---|
| 420 | 451 | final msgType = map['type'] as String? ?? 'text'; |
|---|
| 421 | | - final content = map['content'] as String? ?? map['transcript'] as String? ?? map['caption'] as String? ?? ''; |
|---|
| 452 | + final content = |
|---|
| 453 | + map['content'] as String? ?? |
|---|
| 454 | + map['transcript'] as String? ?? |
|---|
| 455 | + map['caption'] as String? ?? |
|---|
| 456 | + ''; |
|---|
| 422 | 457 | final msgSessionId = map['sessionId'] as String?; |
|---|
| 423 | 458 | final imageData = map['imageBase64'] as String?; |
|---|
| 424 | 459 | |
|---|
| 425 | 460 | // Skip empty text messages (images with no caption are OK) |
|---|
| 426 | 461 | if (content.isEmpty && imageData == null) continue; |
|---|
| 427 | 462 | // Dedup by content (skip images from dedup — they have unique msgIds) |
|---|
| 428 | | - if (imageData == null && content.isNotEmpty && existingContents.contains(content)) continue; |
|---|
| 463 | + if (imageData == null && |
|---|
| 464 | + content.isNotEmpty && |
|---|
| 465 | + existingContents.contains(content)) |
|---|
| 466 | + continue; |
|---|
| 429 | 467 | |
|---|
| 430 | 468 | final Message message; |
|---|
| 431 | 469 | if (msgType == 'image' && imageData != null) { |
|---|
| .. | .. |
|---|
| 444 | 482 | ); |
|---|
| 445 | 483 | } |
|---|
| 446 | 484 | |
|---|
| 447 | | - _chatLog('catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"'); |
|---|
| 485 | + _chatLog( |
|---|
| 486 | + 'catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"', |
|---|
| 487 | + ); |
|---|
| 448 | 488 | |
|---|
| 449 | 489 | if (msgSessionId == null || msgSessionId == currentId) { |
|---|
| 450 | 490 | // Active session or no session: add to UI (addMessage also appends to log). |
|---|
| .. | .. |
|---|
| 453 | 493 | // Cross-session: synchronous append — no race condition. |
|---|
| 454 | 494 | MessageStoreV2.append(msgSessionId, message); |
|---|
| 455 | 495 | _incrementUnread(msgSessionId); |
|---|
| 456 | | - crossSessionCounts[msgSessionId] = (crossSessionCounts[msgSessionId] ?? 0) + 1; |
|---|
| 496 | + crossSessionCounts[msgSessionId] = |
|---|
| 497 | + (crossSessionCounts[msgSessionId] ?? 0) + 1; |
|---|
| 457 | 498 | crossSessionPreviews.putIfAbsent(msgSessionId, () => content); |
|---|
| 458 | 499 | } |
|---|
| 459 | 500 | existingContents.add(content); |
|---|
| .. | .. |
|---|
| 470 | 511 | final count = entry.value; |
|---|
| 471 | 512 | final session = sessions.firstWhere( |
|---|
| 472 | 513 | (s) => s.id == sid, |
|---|
| 473 | | - orElse: () => Session(id: sid, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 514 | + orElse: () => |
|---|
| 515 | + Session(id: sid, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 474 | 516 | ); |
|---|
| 475 | 517 | final preview = count == 1 |
|---|
| 476 | 518 | ? (crossSessionPreviews[sid] ?? '') |
|---|
| .. | .. |
|---|
| 478 | 520 | ToastManager.show( |
|---|
| 479 | 521 | context, |
|---|
| 480 | 522 | sessionName: session.name, |
|---|
| 481 | | - preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview, |
|---|
| 523 | + preview: preview.length > 100 |
|---|
| 524 | + ? '${preview.substring(0, 100)}...' |
|---|
| 525 | + : preview, |
|---|
| 482 | 526 | onTap: () => _switchSession(sid), |
|---|
| 483 | 527 | ); |
|---|
| 484 | 528 | } |
|---|
| .. | .. |
|---|
| 486 | 530 | |
|---|
| 487 | 531 | // Clear unread for active session |
|---|
| 488 | 532 | if (activeId != null) { |
|---|
| 489 | | - final counts = Map<String, int>.from(ref.read(unreadCountsProvider)); |
|---|
| 533 | + final counts = Map<String, int>.from( |
|---|
| 534 | + ref.read(unreadCountsProvider), |
|---|
| 535 | + ); |
|---|
| 490 | 536 | counts.remove(activeId); |
|---|
| 491 | 537 | ref.read(unreadCountsProvider.notifier).state = counts; |
|---|
| 492 | 538 | } |
|---|
| .. | .. |
|---|
| 523 | 569 | ref.read(activeSessionIdProvider.notifier).state = active.id; |
|---|
| 524 | 570 | // Synchronous session switch — no async gap. |
|---|
| 525 | 571 | ref.read(messagesProvider.notifier).switchSession(active.id); |
|---|
| 526 | | - SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', active.id)); |
|---|
| 572 | + SharedPreferences.getInstance().then( |
|---|
| 573 | + (p) => p.setString('activeSessionId', active.id), |
|---|
| 574 | + ); |
|---|
| 527 | 575 | } |
|---|
| 528 | 576 | |
|---|
| 529 | 577 | // Session is ready — process any pending messages that arrived before sessions list |
|---|
| .. | .. |
|---|
| 542 | 590 | } |
|---|
| 543 | 591 | } |
|---|
| 544 | 592 | |
|---|
| 593 | + void _handleProjects(Map<String, dynamic> msg) { |
|---|
| 594 | + final list = msg['projects'] as List<dynamic>?; |
|---|
| 595 | + if (list == null) return; |
|---|
| 596 | + ref.read(projectsProvider.notifier).state = list |
|---|
| 597 | + .map((p) => Project.fromJson(p as Map<String, dynamic>)) |
|---|
| 598 | + .toList(); |
|---|
| 599 | + } |
|---|
| 600 | + |
|---|
| 545 | 601 | /// Respond to a pailot_debug_state request from the server. |
|---|
| 546 | 602 | /// Reads the in-memory session list and active session from providers |
|---|
| 547 | 603 | /// and publishes exactly what the app is currently rendering. |
|---|
| .. | .. |
|---|
| 549 | 605 | final sessions = ref.read(sessionsProvider); |
|---|
| 550 | 606 | final activeSessionId = ref.read(activeSessionIdProvider); |
|---|
| 551 | 607 | |
|---|
| 552 | | - final sessionPayloads = sessions.map((s) => { |
|---|
| 553 | | - 'sessionId': s.id, |
|---|
| 554 | | - 'index': s.index, |
|---|
| 555 | | - 'displayedName': s.name, // exactly what is shown in the drawer |
|---|
| 556 | | - 'type': s.type, |
|---|
| 557 | | - if (s.kind != null) 'kind': s.kind, |
|---|
| 558 | | - 'isActive': s.id == activeSessionId, |
|---|
| 559 | | - }).toList(); |
|---|
| 608 | + final sessionPayloads = sessions |
|---|
| 609 | + .map( |
|---|
| 610 | + (s) => { |
|---|
| 611 | + 'sessionId': s.id, |
|---|
| 612 | + 'index': s.index, |
|---|
| 613 | + 'displayedName': s.name, // exactly what is shown in the drawer |
|---|
| 614 | + 'type': s.type, |
|---|
| 615 | + if (s.kind != null) 'kind': s.kind, |
|---|
| 616 | + 'isActive': s.id == activeSessionId, |
|---|
| 617 | + }, |
|---|
| 618 | + ) |
|---|
| 619 | + .toList(); |
|---|
| 560 | 620 | |
|---|
| 561 | 621 | _ws?.publishDebugStateResponse( |
|---|
| 562 | 622 | requestId: requestId, |
|---|
| .. | .. |
|---|
| 568 | 628 | |
|---|
| 569 | 629 | void _handleIncomingMessage(Map<String, dynamic> msg) { |
|---|
| 570 | 630 | final sessionId = msg['sessionId'] as String?; |
|---|
| 571 | | - final content = msg['content'] as String? ?? |
|---|
| 572 | | - msg['text'] as String? ?? |
|---|
| 573 | | - ''; |
|---|
| 631 | + final content = msg['content'] as String? ?? msg['text'] as String? ?? ''; |
|---|
| 574 | 632 | |
|---|
| 575 | 633 | TraceService.instance.addTrace( |
|---|
| 576 | 634 | 'handleMessage processing type=text', |
|---|
| .. | .. |
|---|
| 597 | 655 | final sessions = ref.read(sessionsProvider); |
|---|
| 598 | 656 | final session = sessions.firstWhere( |
|---|
| 599 | 657 | (s) => s.id == sessionId, |
|---|
| 600 | | - orElse: () => Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 658 | + orElse: () => |
|---|
| 659 | + Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 601 | 660 | ); |
|---|
| 602 | 661 | if (mounted) { |
|---|
| 603 | 662 | ToastManager.show( |
|---|
| 604 | 663 | context, |
|---|
| 605 | 664 | sessionName: session.name, |
|---|
| 606 | | - preview: content.length > 100 ? '${content.substring(0, 100)}...' : content, |
|---|
| 665 | + preview: content.length > 100 |
|---|
| 666 | + ? '${content.substring(0, 100)}...' |
|---|
| 667 | + : content, |
|---|
| 607 | 668 | onTap: () => _switchSession(sessionId), |
|---|
| 608 | 669 | ); |
|---|
| 609 | 670 | } |
|---|
| .. | .. |
|---|
| 620 | 681 | |
|---|
| 621 | 682 | Future<void> _handleIncomingVoice(Map<String, dynamic> msg) async { |
|---|
| 622 | 683 | final sessionId = msg['sessionId'] as String?; |
|---|
| 623 | | - final audioData = msg['audioBase64'] as String? ?? msg['audio'] as String? ?? msg['data'] as String?; |
|---|
| 624 | | - final content = msg['content'] as String? ?? msg['transcript'] as String? ?? msg['text'] as String? ?? ''; |
|---|
| 684 | + final audioData = |
|---|
| 685 | + msg['audioBase64'] as String? ?? |
|---|
| 686 | + msg['audio'] as String? ?? |
|---|
| 687 | + msg['data'] as String?; |
|---|
| 688 | + final content = |
|---|
| 689 | + msg['content'] as String? ?? |
|---|
| 690 | + msg['transcript'] as String? ?? |
|---|
| 691 | + msg['text'] as String? ?? |
|---|
| 692 | + ''; |
|---|
| 625 | 693 | final duration = msg['duration'] as int?; |
|---|
| 626 | 694 | |
|---|
| 627 | 695 | final message = Message( |
|---|
| 628 | | - id: msg['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(), |
|---|
| 696 | + id: |
|---|
| 697 | + msg['id'] as String? ?? |
|---|
| 698 | + DateTime.now().millisecondsSinceEpoch.toString(), |
|---|
| 629 | 699 | role: MessageRole.assistant, |
|---|
| 630 | 700 | type: MessageType.voice, |
|---|
| 631 | 701 | content: content, |
|---|
| .. | .. |
|---|
| 641 | 711 | try { |
|---|
| 642 | 712 | final dir = await getTemporaryDirectory(); |
|---|
| 643 | 713 | savedAudioPath = '${dir.path}/voice_${message.id}.m4a'; |
|---|
| 644 | | - final bytes = base64Decode(audioData.contains(',') ? audioData.split(',').last : audioData); |
|---|
| 714 | + final bytes = base64Decode( |
|---|
| 715 | + audioData.contains(',') ? audioData.split(',').last : audioData, |
|---|
| 716 | + ); |
|---|
| 645 | 717 | await File(savedAudioPath).writeAsBytes(bytes); |
|---|
| 646 | 718 | } catch (_) { |
|---|
| 647 | 719 | savedAudioPath = null; |
|---|
| .. | .. |
|---|
| 660 | 732 | ); |
|---|
| 661 | 733 | |
|---|
| 662 | 734 | final currentId = ref.read(messagesProvider.notifier).currentSessionId; |
|---|
| 663 | | - _chatLog('voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"'); |
|---|
| 735 | + _chatLog( |
|---|
| 736 | + 'voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"', |
|---|
| 737 | + ); |
|---|
| 664 | 738 | if (sessionId != null && sessionId != currentId) { |
|---|
| 665 | 739 | _chatLog('voice: cross-session, appending to store for $sessionId'); |
|---|
| 666 | 740 | // Synchronous append — no async gap, no race condition. |
|---|
| .. | .. |
|---|
| 670 | 744 | final sessions = ref.read(sessionsProvider); |
|---|
| 671 | 745 | final session = sessions.firstWhere( |
|---|
| 672 | 746 | (s) => s.id == sessionId, |
|---|
| 673 | | - orElse: () => Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 747 | + orElse: () => |
|---|
| 748 | + Session(id: sessionId, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 674 | 749 | ); |
|---|
| 675 | 750 | if (mounted) { |
|---|
| 676 | 751 | ToastManager.show( |
|---|
| .. | .. |
|---|
| 687 | 762 | ref.read(isTypingProvider.notifier).state = false; |
|---|
| 688 | 763 | _scrollToBottom(); |
|---|
| 689 | 764 | |
|---|
| 690 | | - if (audioData != null && !AudioService.isBackgrounded && !_isCatchingUp && !_isRecording) { |
|---|
| 765 | + if (audioData != null && |
|---|
| 766 | + !AudioService.isBackgrounded && |
|---|
| 767 | + !_isCatchingUp && |
|---|
| 768 | + !_isRecording) { |
|---|
| 691 | 769 | setState(() => _playingMessageId = storedMessage.id); |
|---|
| 692 | 770 | AudioService.queueBase64(audioData); |
|---|
| 693 | 771 | } |
|---|
| 694 | 772 | } |
|---|
| 695 | 773 | |
|---|
| 696 | 774 | void _handleIncomingImage(Map<String, dynamic> msg) { |
|---|
| 697 | | - final imageData = msg['imageBase64'] as String? ?? msg['data'] as String? ?? msg['image'] as String?; |
|---|
| 698 | | - final content = msg['content'] as String? ?? msg['caption'] as String? ?? ''; |
|---|
| 775 | + final imageData = |
|---|
| 776 | + msg['imageBase64'] as String? ?? |
|---|
| 777 | + msg['data'] as String? ?? |
|---|
| 778 | + msg['image'] as String?; |
|---|
| 779 | + final content = |
|---|
| 780 | + msg['content'] as String? ?? msg['caption'] as String? ?? ''; |
|---|
| 699 | 781 | final sessionId = msg['sessionId'] as String?; |
|---|
| 700 | 782 | |
|---|
| 701 | 783 | if (imageData == null) return; |
|---|
| .. | .. |
|---|
| 703 | 785 | // Always update the Navigate screen screenshot provider |
|---|
| 704 | 786 | ref.read(latestScreenshotProvider.notifier).state = imageData; |
|---|
| 705 | 787 | |
|---|
| 706 | | - final isScreenshot = content == 'Screenshot' || |
|---|
| 788 | + final isScreenshot = |
|---|
| 789 | + content == 'Screenshot' || |
|---|
| 707 | 790 | content == 'Capturing screenshot...' || |
|---|
| 708 | 791 | (msg['type'] == 'screenshot'); |
|---|
| 709 | 792 | |
|---|
| 710 | 793 | if (isScreenshot) { |
|---|
| 711 | 794 | // Remove any "Capturing screenshot..." placeholder text messages |
|---|
| 712 | | - ref.read(messagesProvider.notifier).removeWhere( |
|---|
| 713 | | - (m) => m.role == MessageRole.assistant && m.content == 'Capturing screenshot...', |
|---|
| 714 | | - ); |
|---|
| 795 | + ref |
|---|
| 796 | + .read(messagesProvider.notifier) |
|---|
| 797 | + .removeWhere( |
|---|
| 798 | + (m) => |
|---|
| 799 | + m.role == MessageRole.assistant && |
|---|
| 800 | + m.content == 'Capturing screenshot...', |
|---|
| 801 | + ); |
|---|
| 715 | 802 | |
|---|
| 716 | 803 | // Only add to chat if the Screen button explicitly requested it |
|---|
| 717 | 804 | if (!_screenshotForChat) { |
|---|
| .. | .. |
|---|
| 758 | 845 | /// in-place edits). The transcript is updated in-memory if the message is |
|---|
| 759 | 846 | /// in the active session. Cross-session transcript updates are a no-op. |
|---|
| 760 | 847 | Future<void> _updateTranscriptOnDisk(String messageId, String content) async { |
|---|
| 761 | | - _chatLog('transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)'); |
|---|
| 848 | + _chatLog( |
|---|
| 849 | + 'transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)', |
|---|
| 850 | + ); |
|---|
| 762 | 851 | } |
|---|
| 763 | 852 | |
|---|
| 764 | 853 | void _incrementUnread(String sessionId) { |
|---|
| .. | .. |
|---|
| 789 | 878 | ref.read(activeSessionIdProvider.notifier).state = sessionId; |
|---|
| 790 | 879 | // Synchronous — no async gap between session switch and incoming messages. |
|---|
| 791 | 880 | ref.read(messagesProvider.notifier).switchSession(sessionId); |
|---|
| 792 | | - SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId)); |
|---|
| 881 | + SharedPreferences.getInstance().then( |
|---|
| 882 | + (p) => p.setString('activeSessionId', sessionId), |
|---|
| 883 | + ); |
|---|
| 793 | 884 | |
|---|
| 794 | 885 | final counts = Map<String, int>.from(ref.read(unreadCountsProvider)); |
|---|
| 795 | 886 | counts.remove(sessionId); |
|---|
| .. | .. |
|---|
| 1023 | 1114 | final mime = att['mimeType'] as String; |
|---|
| 1024 | 1115 | final name = att['fileName'] as String? ?? 'file'; |
|---|
| 1025 | 1116 | if (mime.startsWith('image/')) { |
|---|
| 1026 | | - ref.read(messagesProvider.notifier).addMessage(Message.image( |
|---|
| 1027 | | - role: MessageRole.user, |
|---|
| 1028 | | - imageBase64: att['data'] as String, |
|---|
| 1029 | | - content: name, |
|---|
| 1030 | | - status: MessageStatus.sent, |
|---|
| 1031 | | - )); |
|---|
| 1117 | + ref |
|---|
| 1118 | + .read(messagesProvider.notifier) |
|---|
| 1119 | + .addMessage( |
|---|
| 1120 | + Message.image( |
|---|
| 1121 | + role: MessageRole.user, |
|---|
| 1122 | + imageBase64: att['data'] as String, |
|---|
| 1123 | + content: name, |
|---|
| 1124 | + status: MessageStatus.sent, |
|---|
| 1125 | + ), |
|---|
| 1126 | + ); |
|---|
| 1032 | 1127 | } else { |
|---|
| 1033 | 1128 | final size = base64Decode(att['data'] as String).length; |
|---|
| 1034 | | - ref.read(messagesProvider.notifier).addMessage(Message.text( |
|---|
| 1035 | | - role: MessageRole.user, |
|---|
| 1036 | | - content: textCaption.isNotEmpty |
|---|
| 1037 | | - ? '$textCaption\n📎 $name (${_formatSize(size)})' |
|---|
| 1038 | | - : '📎 $name (${_formatSize(size)})', |
|---|
| 1039 | | - status: MessageStatus.sent, |
|---|
| 1040 | | - )); |
|---|
| 1129 | + ref |
|---|
| 1130 | + .read(messagesProvider.notifier) |
|---|
| 1131 | + .addMessage( |
|---|
| 1132 | + Message.text( |
|---|
| 1133 | + role: MessageRole.user, |
|---|
| 1134 | + content: textCaption.isNotEmpty |
|---|
| 1135 | + ? '$textCaption\n📎 $name (${_formatSize(size)})' |
|---|
| 1136 | + : '📎 $name (${_formatSize(size)})', |
|---|
| 1137 | + status: MessageStatus.sent, |
|---|
| 1138 | + ), |
|---|
| 1139 | + ); |
|---|
| 1041 | 1140 | } |
|---|
| 1042 | 1141 | } |
|---|
| 1043 | 1142 | |
|---|
| .. | .. |
|---|
| 1049 | 1148 | String _guessMimeType(String name) { |
|---|
| 1050 | 1149 | final ext = name.split('.').last.toLowerCase(); |
|---|
| 1051 | 1150 | const map = { |
|---|
| 1052 | | - 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', |
|---|
| 1053 | | - 'gif': 'image/gif', 'webp': 'image/webp', 'heic': 'image/heic', |
|---|
| 1054 | | - 'pdf': 'application/pdf', 'doc': 'application/msword', |
|---|
| 1055 | | - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
|---|
| 1151 | + 'jpg': 'image/jpeg', |
|---|
| 1152 | + 'jpeg': 'image/jpeg', |
|---|
| 1153 | + 'png': 'image/png', |
|---|
| 1154 | + 'gif': 'image/gif', |
|---|
| 1155 | + 'webp': 'image/webp', |
|---|
| 1156 | + 'heic': 'image/heic', |
|---|
| 1157 | + 'pdf': 'application/pdf', |
|---|
| 1158 | + 'doc': 'application/msword', |
|---|
| 1159 | + 'docx': |
|---|
| 1160 | + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
|---|
| 1056 | 1161 | 'xls': 'application/vnd.ms-excel', |
|---|
| 1057 | | - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
|---|
| 1058 | | - 'txt': 'text/plain', 'csv': 'text/csv', 'json': 'application/json', |
|---|
| 1059 | | - 'zip': 'application/zip', 'mp3': 'audio/mpeg', 'mp4': 'video/mp4', |
|---|
| 1162 | + 'xlsx': |
|---|
| 1163 | + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
|---|
| 1164 | + 'txt': 'text/plain', |
|---|
| 1165 | + 'csv': 'text/csv', |
|---|
| 1166 | + 'json': 'application/json', |
|---|
| 1167 | + 'zip': 'application/zip', |
|---|
| 1168 | + 'mp3': 'audio/mpeg', |
|---|
| 1169 | + 'mp4': 'video/mp4', |
|---|
| 1060 | 1170 | }; |
|---|
| 1061 | 1171 | return map[ext] ?? 'application/octet-stream'; |
|---|
| 1062 | 1172 | } |
|---|
| .. | .. |
|---|
| 1069 | 1179 | |
|---|
| 1070 | 1180 | void _requestScreenshot() { |
|---|
| 1071 | 1181 | _screenshotForChat = true; |
|---|
| 1072 | | - _sendCommand('screenshot', {'sessionId': ref.read(activeSessionIdProvider)}); |
|---|
| 1182 | + _sendCommand('screenshot', { |
|---|
| 1183 | + 'sessionId': ref.read(activeSessionIdProvider), |
|---|
| 1184 | + }); |
|---|
| 1073 | 1185 | if (mounted) { |
|---|
| 1074 | 1186 | ScaffoldMessenger.of(context).showSnackBar( |
|---|
| 1075 | 1187 | const SnackBar( |
|---|
| .. | .. |
|---|
| 1169 | 1281 | textCaption = ''; |
|---|
| 1170 | 1282 | } |
|---|
| 1171 | 1283 | |
|---|
| 1172 | | - final attachments = encodedImages.map((b64) => |
|---|
| 1173 | | - <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'} |
|---|
| 1174 | | - ).toList(); |
|---|
| 1284 | + final attachments = encodedImages |
|---|
| 1285 | + .map((b64) => <String, dynamic>{'data': b64, 'mimeType': 'image/jpeg'}) |
|---|
| 1286 | + .toList(); |
|---|
| 1175 | 1287 | |
|---|
| 1176 | 1288 | // Create the first image message early so we have its ID for transcript reflection |
|---|
| 1177 | 1289 | final firstImageMsg = Message.image( |
|---|
| 1178 | 1290 | role: MessageRole.user, |
|---|
| 1179 | 1291 | imageBase64: encodedImages[0], |
|---|
| 1180 | | - content: textCaption.isNotEmpty ? textCaption : (voiceB64 != null ? '🎤 ...' : ''), |
|---|
| 1292 | + content: textCaption.isNotEmpty |
|---|
| 1293 | + ? textCaption |
|---|
| 1294 | + : (voiceB64 != null ? '🎤 ...' : ''), |
|---|
| 1181 | 1295 | status: MessageStatus.sent, |
|---|
| 1182 | 1296 | ); |
|---|
| 1183 | 1297 | |
|---|
| .. | .. |
|---|
| 1251 | 1365 | child: const Row( |
|---|
| 1252 | 1366 | mainAxisAlignment: MainAxisAlignment.center, |
|---|
| 1253 | 1367 | children: [ |
|---|
| 1254 | | - Icon(Icons.fiber_manual_record, color: Colors.red, size: 16), |
|---|
| 1368 | + Icon( |
|---|
| 1369 | + Icons.fiber_manual_record, |
|---|
| 1370 | + color: Colors.red, |
|---|
| 1371 | + size: 16, |
|---|
| 1372 | + ), |
|---|
| 1255 | 1373 | SizedBox(width: 8), |
|---|
| 1256 | | - Text('Recording voice caption...', style: TextStyle(fontSize: 16)), |
|---|
| 1374 | + Text( |
|---|
| 1375 | + 'Recording voice caption...', |
|---|
| 1376 | + style: TextStyle(fontSize: 16), |
|---|
| 1377 | + ), |
|---|
| 1257 | 1378 | ], |
|---|
| 1258 | 1379 | ), |
|---|
| 1259 | 1380 | ), |
|---|
| .. | .. |
|---|
| 1266 | 1387 | children: [ |
|---|
| 1267 | 1388 | Icon(Icons.check_circle, color: Colors.green, size: 20), |
|---|
| 1268 | 1389 | SizedBox(width: 8), |
|---|
| 1269 | | - Text('Voice caption recorded', style: TextStyle(fontSize: 16)), |
|---|
| 1390 | + Text( |
|---|
| 1391 | + 'Voice caption recorded', |
|---|
| 1392 | + style: TextStyle(fontSize: 16), |
|---|
| 1393 | + ), |
|---|
| 1270 | 1394 | ], |
|---|
| 1271 | 1395 | ), |
|---|
| 1272 | 1396 | ), |
|---|
| .. | .. |
|---|
| 1357 | 1481 | Navigator.pop(ctx); |
|---|
| 1358 | 1482 | ref.read(messagesProvider.notifier).clearMessages(); |
|---|
| 1359 | 1483 | }, |
|---|
| 1360 | | - child: const Text('Clear', style: TextStyle(color: AppColors.error)), |
|---|
| 1484 | + child: const Text( |
|---|
| 1485 | + 'Clear', |
|---|
| 1486 | + style: TextStyle(color: AppColors.error), |
|---|
| 1487 | + ), |
|---|
| 1361 | 1488 | ), |
|---|
| 1362 | 1489 | ], |
|---|
| 1363 | 1490 | ), |
|---|
| .. | .. |
|---|
| 1376 | 1503 | final next = current == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark; |
|---|
| 1377 | 1504 | ref.read(themeModeProvider.notifier).state = next; |
|---|
| 1378 | 1505 | final prefs = await SharedPreferences.getInstance(); |
|---|
| 1379 | | - await prefs.setString('theme_mode', next == ThemeMode.dark ? 'dark' : 'light'); |
|---|
| 1506 | + await prefs.setString( |
|---|
| 1507 | + 'theme_mode', |
|---|
| 1508 | + next == ThemeMode.dark ? 'dark' : 'light', |
|---|
| 1509 | + ); |
|---|
| 1380 | 1510 | } |
|---|
| 1381 | 1511 | |
|---|
| 1382 | 1512 | void _scrollToBottom() { |
|---|
| .. | .. |
|---|
| 1392 | 1522 | } |
|---|
| 1393 | 1523 | |
|---|
| 1394 | 1524 | void _handleNewSession() { |
|---|
| 1395 | | - _sendCommand('create'); |
|---|
| 1525 | + // Close the drawer, fetch the latest project list, and present the launcher. |
|---|
| 1526 | + Navigator.of(context).pop(); |
|---|
| 1527 | + _sendCommand('projects'); |
|---|
| 1528 | + showModalBottomSheet( |
|---|
| 1529 | + context: context, |
|---|
| 1530 | + isScrollControlled: true, |
|---|
| 1531 | + backgroundColor: Theme.of(context).canvasColor, |
|---|
| 1532 | + shape: const RoundedRectangleBorder( |
|---|
| 1533 | + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), |
|---|
| 1534 | + ), |
|---|
| 1535 | + builder: (_) => NewSessionSheet( |
|---|
| 1536 | + onLaunchProject: (project, rehome) { |
|---|
| 1537 | + final activeId = ref.read(activeSessionIdProvider); |
|---|
| 1538 | + if (rehome && activeId != null) { |
|---|
| 1539 | + _sendCommand('rehome', { |
|---|
| 1540 | + 'sessionId': activeId, |
|---|
| 1541 | + 'path': project.path, |
|---|
| 1542 | + 'name': project.name, |
|---|
| 1543 | + }); |
|---|
| 1544 | + } else { |
|---|
| 1545 | + _sendCommand('create', { |
|---|
| 1546 | + 'project': project.launch, |
|---|
| 1547 | + 'name': project.name, |
|---|
| 1548 | + }); |
|---|
| 1549 | + } |
|---|
| 1550 | + }, |
|---|
| 1551 | + onLaunchPath: (path, name, rehome) { |
|---|
| 1552 | + final activeId = ref.read(activeSessionIdProvider); |
|---|
| 1553 | + if (rehome && activeId != null) { |
|---|
| 1554 | + _sendCommand('rehome', { |
|---|
| 1555 | + 'sessionId': activeId, |
|---|
| 1556 | + 'path': path, |
|---|
| 1557 | + 'name': name, |
|---|
| 1558 | + }); |
|---|
| 1559 | + } else { |
|---|
| 1560 | + _sendCommand('create', {'path': path, 'name': name}); |
|---|
| 1561 | + } |
|---|
| 1562 | + }, |
|---|
| 1563 | + ), |
|---|
| 1564 | + ); |
|---|
| 1396 | 1565 | } |
|---|
| 1397 | 1566 | |
|---|
| 1398 | 1567 | /// Called when the user taps an upgrade CTA in the drawer or paywall banner. |
|---|
| .. | .. |
|---|
| 1411 | 1580 | void _handleSessionRemove(Session session) { |
|---|
| 1412 | 1581 | _sendCommand('remove', {'sessionId': session.id}); |
|---|
| 1413 | 1582 | final sessions = ref.read(sessionsProvider); |
|---|
| 1414 | | - ref.read(sessionsProvider.notifier).state = |
|---|
| 1415 | | - sessions.where((s) => s.id != session.id).toList(); |
|---|
| 1583 | + ref.read(sessionsProvider.notifier).state = sessions |
|---|
| 1584 | + .where((s) => s.id != session.id) |
|---|
| 1585 | + .toList(); |
|---|
| 1416 | 1586 | } |
|---|
| 1417 | 1587 | |
|---|
| 1418 | 1588 | void _handleSessionReorder(int oldIndex, int newIndex) { |
|---|
| .. | .. |
|---|
| 1428 | 1598 | } |
|---|
| 1429 | 1599 | |
|---|
| 1430 | 1600 | void _saveSessionOrder(List<String> ids) { |
|---|
| 1431 | | - SharedPreferences.getInstance().then((p) => p.setStringList('sessionOrder', ids)); |
|---|
| 1601 | + SharedPreferences.getInstance().then( |
|---|
| 1602 | + (p) => p.setStringList('sessionOrder', ids), |
|---|
| 1603 | + ); |
|---|
| 1432 | 1604 | } |
|---|
| 1433 | 1605 | |
|---|
| 1434 | 1606 | /// Apply saved custom order to a server-provided session list. |
|---|
| 1435 | 1607 | /// New sessions (not in saved order) are appended at the end. |
|---|
| 1436 | 1608 | List<Session> _applyCustomOrder(List<Session> sessions) { |
|---|
| 1437 | | - if (_cachedSessionOrder == null || _cachedSessionOrder!.isEmpty) return sessions; |
|---|
| 1609 | + if (_cachedSessionOrder == null || _cachedSessionOrder!.isEmpty) |
|---|
| 1610 | + return sessions; |
|---|
| 1438 | 1611 | final order = _cachedSessionOrder!; |
|---|
| 1439 | 1612 | final byId = {for (final s in sessions) s.id: s}; |
|---|
| 1440 | 1613 | final ordered = <Session>[]; |
|---|
| .. | .. |
|---|
| 1476 | 1649 | behavior: HitTestBehavior.translucent, |
|---|
| 1477 | 1650 | onTap: () => FocusScope.of(context).unfocus(), |
|---|
| 1478 | 1651 | child: Scaffold( |
|---|
| 1479 | | - key: _scaffoldKey, |
|---|
| 1480 | | - appBar: AppBar( |
|---|
| 1481 | | - leading: IconButton( |
|---|
| 1482 | | - icon: const Icon(Icons.menu), |
|---|
| 1483 | | - onPressed: () { |
|---|
| 1484 | | - FocusScope.of(context).unfocus(); |
|---|
| 1485 | | - _scaffoldKey.currentState?.openDrawer(); |
|---|
| 1486 | | - }, |
|---|
| 1487 | | - ), |
|---|
| 1488 | | - title: Column( |
|---|
| 1489 | | - crossAxisAlignment: CrossAxisAlignment.center, |
|---|
| 1490 | | - mainAxisSize: MainAxisSize.min, |
|---|
| 1491 | | - children: [ |
|---|
| 1492 | | - Text( |
|---|
| 1493 | | - activeSession?.name ?? 'PAILot', |
|---|
| 1494 | | - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), |
|---|
| 1652 | + key: _scaffoldKey, |
|---|
| 1653 | + appBar: AppBar( |
|---|
| 1654 | + leading: IconButton( |
|---|
| 1655 | + icon: const Icon(Icons.menu), |
|---|
| 1656 | + onPressed: () { |
|---|
| 1657 | + FocusScope.of(context).unfocus(); |
|---|
| 1658 | + _scaffoldKey.currentState?.openDrawer(); |
|---|
| 1659 | + }, |
|---|
| 1660 | + ), |
|---|
| 1661 | + title: Column( |
|---|
| 1662 | + crossAxisAlignment: CrossAxisAlignment.center, |
|---|
| 1663 | + mainAxisSize: MainAxisSize.min, |
|---|
| 1664 | + children: [ |
|---|
| 1665 | + Text( |
|---|
| 1666 | + activeSession?.name ?? 'PAILot', |
|---|
| 1667 | + style: const TextStyle( |
|---|
| 1668 | + fontSize: 16, |
|---|
| 1669 | + fontWeight: FontWeight.w600, |
|---|
| 1670 | + ), |
|---|
| 1671 | + ), |
|---|
| 1672 | + if (connectionDetail.isNotEmpty && |
|---|
| 1673 | + wsStatus != ConnectionStatus.connected) |
|---|
| 1674 | + Text( |
|---|
| 1675 | + connectionDetail, |
|---|
| 1676 | + style: TextStyle(fontSize: 11, color: Colors.grey.shade400), |
|---|
| 1677 | + ), |
|---|
| 1678 | + if (wsStatus == ConnectionStatus.connected && |
|---|
| 1679 | + ref.watch(connectedViaProvider).isNotEmpty) |
|---|
| 1680 | + Text( |
|---|
| 1681 | + 'via ${ref.watch(connectedViaProvider)}', |
|---|
| 1682 | + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), |
|---|
| 1683 | + ), |
|---|
| 1684 | + ], |
|---|
| 1685 | + ), |
|---|
| 1686 | + actions: [ |
|---|
| 1687 | + StatusDot(status: wsStatus), |
|---|
| 1688 | + const SizedBox(width: 12), |
|---|
| 1689 | + IconButton( |
|---|
| 1690 | + icon: Icon( |
|---|
| 1691 | + Theme.of(context).brightness == Brightness.dark |
|---|
| 1692 | + ? Icons.light_mode |
|---|
| 1693 | + : Icons.dark_mode, |
|---|
| 1694 | + size: 20, |
|---|
| 1695 | + ), |
|---|
| 1696 | + onPressed: _toggleTheme, |
|---|
| 1495 | 1697 | ), |
|---|
| 1496 | | - if (connectionDetail.isNotEmpty && wsStatus != ConnectionStatus.connected) |
|---|
| 1497 | | - Text( |
|---|
| 1498 | | - connectionDetail, |
|---|
| 1499 | | - style: TextStyle(fontSize: 11, color: Colors.grey.shade400), |
|---|
| 1500 | | - ), |
|---|
| 1501 | | - if (wsStatus == ConnectionStatus.connected && ref.watch(connectedViaProvider).isNotEmpty) |
|---|
| 1502 | | - Text( |
|---|
| 1503 | | - 'via ${ref.watch(connectedViaProvider)}', |
|---|
| 1504 | | - style: TextStyle(fontSize: 11, color: Colors.grey.shade500), |
|---|
| 1505 | | - ), |
|---|
| 1698 | + IconButton( |
|---|
| 1699 | + icon: const Icon(Icons.settings, size: 20), |
|---|
| 1700 | + onPressed: () => context.push('/settings'), |
|---|
| 1701 | + ), |
|---|
| 1506 | 1702 | ], |
|---|
| 1507 | 1703 | ), |
|---|
| 1508 | | - actions: [ |
|---|
| 1509 | | - StatusDot(status: wsStatus), |
|---|
| 1510 | | - const SizedBox(width: 12), |
|---|
| 1511 | | - IconButton( |
|---|
| 1512 | | - icon: Icon( |
|---|
| 1513 | | - Theme.of(context).brightness == Brightness.dark |
|---|
| 1514 | | - ? Icons.light_mode |
|---|
| 1515 | | - : Icons.dark_mode, |
|---|
| 1516 | | - size: 20, |
|---|
| 1704 | + onDrawerChanged: (isOpened) { |
|---|
| 1705 | + if (isOpened) FocusManager.instance.primaryFocus?.unfocus(); |
|---|
| 1706 | + }, |
|---|
| 1707 | + drawer: SessionDrawer( |
|---|
| 1708 | + sessions: sessions, |
|---|
| 1709 | + activeSessionId: activeSession?.id, |
|---|
| 1710 | + unreadCounts: unreadCounts, |
|---|
| 1711 | + isPro: ref.watch(isProProvider), |
|---|
| 1712 | + onSelect: (s) => _switchSession(s.id), |
|---|
| 1713 | + onRemove: _handleSessionRemove, |
|---|
| 1714 | + onRename: _handleSessionRename, |
|---|
| 1715 | + onReorder: _handleSessionReorder, |
|---|
| 1716 | + onNewSession: _handleNewSession, |
|---|
| 1717 | + onRefresh: _refreshSessions, |
|---|
| 1718 | + onUpgrade: _handleUpgrade, |
|---|
| 1719 | + ), |
|---|
| 1720 | + body: Column( |
|---|
| 1721 | + children: [ |
|---|
| 1722 | + const PaywallBanner(), |
|---|
| 1723 | + Expanded( |
|---|
| 1724 | + child: ListView.builder( |
|---|
| 1725 | + controller: _scrollController, |
|---|
| 1726 | + reverse: true, |
|---|
| 1727 | + padding: const EdgeInsets.only(top: 8, bottom: 8), |
|---|
| 1728 | + itemCount: messages.length + (isTyping ? 1 : 0), |
|---|
| 1729 | + itemBuilder: (context, index) { |
|---|
| 1730 | + if (isTyping && index == 0) { |
|---|
| 1731 | + return const TypingIndicator(); |
|---|
| 1732 | + } |
|---|
| 1733 | + |
|---|
| 1734 | + final msgIndex = isTyping |
|---|
| 1735 | + ? messages.length - index |
|---|
| 1736 | + : messages.length - 1 - index; |
|---|
| 1737 | + |
|---|
| 1738 | + if (msgIndex < 0 || msgIndex >= messages.length) { |
|---|
| 1739 | + return const SizedBox.shrink(); |
|---|
| 1740 | + } |
|---|
| 1741 | + |
|---|
| 1742 | + final message = messages[msgIndex]; |
|---|
| 1743 | + return MessageBubble( |
|---|
| 1744 | + message: message, |
|---|
| 1745 | + isPlaying: _playingMessageId == message.id, |
|---|
| 1746 | + onPlay: message.type == MessageType.voice |
|---|
| 1747 | + ? () => _playMessage(message) |
|---|
| 1748 | + : null, |
|---|
| 1749 | + onChainPlay: |
|---|
| 1750 | + message.type == MessageType.voice && |
|---|
| 1751 | + message.role == MessageRole.assistant |
|---|
| 1752 | + ? () => _chainPlayFrom(message) |
|---|
| 1753 | + : null, |
|---|
| 1754 | + onDelete: () { |
|---|
| 1755 | + ref |
|---|
| 1756 | + .read(messagesProvider.notifier) |
|---|
| 1757 | + .removeMessage(message.id); |
|---|
| 1758 | + }, |
|---|
| 1759 | + ); |
|---|
| 1760 | + }, |
|---|
| 1761 | + ), |
|---|
| 1517 | 1762 | ), |
|---|
| 1518 | | - onPressed: _toggleTheme, |
|---|
| 1519 | | - ), |
|---|
| 1520 | | - IconButton( |
|---|
| 1521 | | - icon: const Icon(Icons.settings, size: 20), |
|---|
| 1522 | | - onPressed: () => context.push('/settings'), |
|---|
| 1523 | | - ), |
|---|
| 1524 | | - ], |
|---|
| 1525 | | - ), |
|---|
| 1526 | | - onDrawerChanged: (isOpened) { |
|---|
| 1527 | | - if (isOpened) FocusManager.instance.primaryFocus?.unfocus(); |
|---|
| 1528 | | - }, |
|---|
| 1529 | | - drawer: SessionDrawer( |
|---|
| 1530 | | - sessions: sessions, |
|---|
| 1531 | | - activeSessionId: activeSession?.id, |
|---|
| 1532 | | - unreadCounts: unreadCounts, |
|---|
| 1533 | | - isPro: ref.watch(isProProvider), |
|---|
| 1534 | | - onSelect: (s) => _switchSession(s.id), |
|---|
| 1535 | | - onRemove: _handleSessionRemove, |
|---|
| 1536 | | - onRename: _handleSessionRename, |
|---|
| 1537 | | - onReorder: _handleSessionReorder, |
|---|
| 1538 | | - onNewSession: _handleNewSession, |
|---|
| 1539 | | - onRefresh: _refreshSessions, |
|---|
| 1540 | | - onUpgrade: _handleUpgrade, |
|---|
| 1541 | | - ), |
|---|
| 1542 | | - body: Column( |
|---|
| 1543 | | - children: [ |
|---|
| 1544 | | - const PaywallBanner(), |
|---|
| 1545 | | - Expanded( |
|---|
| 1546 | | - child: ListView.builder( |
|---|
| 1547 | | - controller: _scrollController, |
|---|
| 1548 | | - reverse: true, |
|---|
| 1549 | | - padding: const EdgeInsets.only(top: 8, bottom: 8), |
|---|
| 1550 | | - itemCount: messages.length + (isTyping ? 1 : 0), |
|---|
| 1551 | | - itemBuilder: (context, index) { |
|---|
| 1552 | | - if (isTyping && index == 0) { |
|---|
| 1553 | | - return const TypingIndicator(); |
|---|
| 1554 | | - } |
|---|
| 1555 | | - |
|---|
| 1556 | | - final msgIndex = isTyping |
|---|
| 1557 | | - ? messages.length - index |
|---|
| 1558 | | - : messages.length - 1 - index; |
|---|
| 1559 | | - |
|---|
| 1560 | | - if (msgIndex < 0 || msgIndex >= messages.length) { |
|---|
| 1561 | | - return const SizedBox.shrink(); |
|---|
| 1562 | | - } |
|---|
| 1563 | | - |
|---|
| 1564 | | - final message = messages[msgIndex]; |
|---|
| 1565 | | - return MessageBubble( |
|---|
| 1566 | | - message: message, |
|---|
| 1567 | | - isPlaying: _playingMessageId == message.id, |
|---|
| 1568 | | - onPlay: message.type == MessageType.voice |
|---|
| 1569 | | - ? () => _playMessage(message) |
|---|
| 1570 | | - : null, |
|---|
| 1571 | | - onChainPlay: message.type == MessageType.voice && |
|---|
| 1572 | | - message.role == MessageRole.assistant |
|---|
| 1573 | | - ? () => _chainPlayFrom(message) |
|---|
| 1574 | | - : null, |
|---|
| 1575 | | - onDelete: () { |
|---|
| 1576 | | - ref.read(messagesProvider.notifier).removeMessage(message.id); |
|---|
| 1577 | | - }, |
|---|
| 1578 | | - ); |
|---|
| 1763 | + CommandBar( |
|---|
| 1764 | + onScreen: _requestScreenshot, |
|---|
| 1765 | + onNavigate: _navigateToTerminal, |
|---|
| 1766 | + onPhoto: _pickPhoto, |
|---|
| 1767 | + onClear: _clearChat, |
|---|
| 1768 | + onHelp: inputMode == InputMode.text ? _sendHelp : null, |
|---|
| 1769 | + showHelp: inputMode == InputMode.text, |
|---|
| 1770 | + ), |
|---|
| 1771 | + InputBar( |
|---|
| 1772 | + mode: inputMode, |
|---|
| 1773 | + isRecording: _isRecording, |
|---|
| 1774 | + textController: _textController, |
|---|
| 1775 | + onToggleMode: () { |
|---|
| 1776 | + ref |
|---|
| 1777 | + .read(inputModeProvider.notifier) |
|---|
| 1778 | + .state = inputMode == InputMode.voice |
|---|
| 1779 | + ? InputMode.text |
|---|
| 1780 | + : InputMode.voice; |
|---|
| 1579 | 1781 | }, |
|---|
| 1782 | + onRecordStart: _startRecording, |
|---|
| 1783 | + onRecordStop: _stopRecording, |
|---|
| 1784 | + onRecordCancel: _cancelRecording, |
|---|
| 1785 | + onReplay: _replayLast, |
|---|
| 1786 | + onSendText: _sendTextMessage, |
|---|
| 1580 | 1787 | ), |
|---|
| 1581 | | - ), |
|---|
| 1582 | | - CommandBar( |
|---|
| 1583 | | - onScreen: _requestScreenshot, |
|---|
| 1584 | | - onNavigate: _navigateToTerminal, |
|---|
| 1585 | | - onPhoto: _pickPhoto, |
|---|
| 1586 | | - onClear: _clearChat, |
|---|
| 1587 | | - onHelp: inputMode == InputMode.text ? _sendHelp : null, |
|---|
| 1588 | | - showHelp: inputMode == InputMode.text, |
|---|
| 1589 | | - ), |
|---|
| 1590 | | - InputBar( |
|---|
| 1591 | | - mode: inputMode, |
|---|
| 1592 | | - isRecording: _isRecording, |
|---|
| 1593 | | - textController: _textController, |
|---|
| 1594 | | - onToggleMode: () { |
|---|
| 1595 | | - ref.read(inputModeProvider.notifier).state = |
|---|
| 1596 | | - inputMode == InputMode.voice |
|---|
| 1597 | | - ? InputMode.text |
|---|
| 1598 | | - : InputMode.voice; |
|---|
| 1599 | | - }, |
|---|
| 1600 | | - onRecordStart: _startRecording, |
|---|
| 1601 | | - onRecordStop: _stopRecording, |
|---|
| 1602 | | - onRecordCancel: _cancelRecording, |
|---|
| 1603 | | - onReplay: _replayLast, |
|---|
| 1604 | | - onSendText: _sendTextMessage, |
|---|
| 1605 | | - ), |
|---|
| 1606 | | - ], |
|---|
| 1788 | + ], |
|---|
| 1789 | + ), |
|---|
| 1607 | 1790 | ), |
|---|
| 1608 | | - ), |
|---|
| 1609 | 1791 | ); |
|---|
| 1610 | 1792 | } |
|---|
| 1611 | 1793 | } |
|---|