fix: text sharing
This commit is contained in:
@@ -541,12 +541,10 @@ final loadConversationProvider = FutureProvider.family<Conversation, String>((
|
||||
|
||||
// Provider to automatically load and set the default model from user settings or OpenWebUI
|
||||
final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
// Initialize the settings watcher
|
||||
ref.watch(_settingsWatcherProvider);
|
||||
// Watch user settings to refresh when default model changes
|
||||
ref.watch(appSettingsProvider);
|
||||
// Handle reviewer mode first
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
// Initialize the settings watcher (side-effect only)
|
||||
ref.read(_settingsWatcherProvider);
|
||||
// Read settings without subscribing to rebuilds to avoid watch/await hazards
|
||||
final reviewerMode = ref.read(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
// Check if a model is manually selected
|
||||
final currentSelected = ref.read(selectedModelProvider);
|
||||
@@ -563,20 +561,18 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
final models = await ref.read(modelsProvider.future);
|
||||
if (models.isNotEmpty) {
|
||||
final defaultModel = models.first;
|
||||
Future.microtask(() {
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).state = defaultModel;
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Auto-selected demo model: ${defaultModel.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).state = defaultModel;
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Auto-selected demo model: ${defaultModel.name}',
|
||||
);
|
||||
}
|
||||
return defaultModel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) return null;
|
||||
|
||||
try {
|
||||
@@ -656,15 +652,11 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
}
|
||||
}
|
||||
|
||||
// Defer the state update to avoid modifying providers during initialization
|
||||
final modelToSet = selectedModel;
|
||||
Future.microtask(() {
|
||||
// Only update if this is not a manual selection
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).state = modelToSet;
|
||||
foundation.debugPrint('DEBUG: Set default model: ${modelToSet.name}');
|
||||
}
|
||||
});
|
||||
// Update selection immediately inside provider context
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).state = selectedModel;
|
||||
foundation.debugPrint('DEBUG: Set default model: ${selectedModel.name}');
|
||||
}
|
||||
|
||||
return selectedModel;
|
||||
} catch (e) {
|
||||
@@ -675,15 +667,12 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
final models = await ref.read(modelsProvider.future);
|
||||
if (models.isNotEmpty) {
|
||||
final fallbackModel = models.first;
|
||||
// Defer the state update
|
||||
Future.microtask(() {
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).state = fallbackModel;
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Fallback to first available model: ${fallbackModel.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).state = fallbackModel;
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Fallback to first available model: ${fallbackModel.name}',
|
||||
);
|
||||
}
|
||||
return fallbackModel;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import '../../features/auth/providers/unified_auth_providers.dart';
|
||||
import '../../features/chat/providers/chat_providers.dart';
|
||||
import '../../features/chat/services/file_attachment_service.dart';
|
||||
import '../../core/providers/app_providers.dart';
|
||||
// No server chat creation here; follow chat flow on first send
|
||||
import 'navigation_service.dart';
|
||||
// Server chat creation/title generation occur on first send via chat providers
|
||||
|
||||
/// Lightweight payload for a share event
|
||||
class SharedPayload {
|
||||
@@ -26,20 +27,21 @@ final pendingSharedPayloadProvider = StateProvider<SharedPayload?>((_) => null);
|
||||
|
||||
/// Initializes listening to OS share intents and handles them
|
||||
final shareReceiverInitializerProvider = Provider<void>((ref) {
|
||||
// Do nothing on web/desktop
|
||||
// Only mobile platforms handle OS share intents
|
||||
if (kIsWeb) return;
|
||||
|
||||
final sub = StreamController<SharedPayload>.broadcast();
|
||||
if (!(Platform.isAndroid || Platform.isIOS)) return;
|
||||
|
||||
// Listen for app readiness: authenticated and model available
|
||||
void maybeProcessPending() {
|
||||
final navState = ref.read(authNavigationStateProvider);
|
||||
final model = ref.read(selectedModelProvider);
|
||||
final pending = ref.read(pendingSharedPayloadProvider);
|
||||
final isOnChatRoute = NavigationService.currentRoute == Routes.chat;
|
||||
if (pending != null &&
|
||||
pending.hasAnything &&
|
||||
navState == AuthNavigationState.authenticated &&
|
||||
model != null) {
|
||||
model != null &&
|
||||
isOnChatRoute) {
|
||||
_processPayload(ref, pending);
|
||||
ref.read(pendingSharedPayloadProvider.notifier).state = null;
|
||||
}
|
||||
@@ -51,6 +53,8 @@ final shareReceiverInitializerProvider = Provider<void>((ref) {
|
||||
(_, __) => maybeProcessPending(),
|
||||
);
|
||||
ref.listen(selectedModelProvider, (_, __) => maybeProcessPending());
|
||||
// Also poll once shortly after navigation settles to ensure ChatPage is ready
|
||||
Future.delayed(const Duration(milliseconds: 150), () => maybeProcessPending());
|
||||
|
||||
// Hook into share_handler
|
||||
final handler = sh.ShareHandler.instance;
|
||||
@@ -85,7 +89,6 @@ final shareReceiverInitializerProvider = Provider<void>((ref) {
|
||||
// Ensure cleanup
|
||||
ref.onDispose(() async {
|
||||
await streamSub.cancel();
|
||||
await sub.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,9 +172,8 @@ Future<void> _processPayload(Ref ref, SharedPayload payload) async {
|
||||
final current = ref.read(inputFocusTriggerProvider);
|
||||
ref.read(inputFocusTriggerProvider.notifier).state = current + 1;
|
||||
}
|
||||
// Do NOT create a placeholder server chat here. The drawer will refresh
|
||||
// when the user sends their first message, matching in-app behavior.
|
||||
// This allows the user to add a caption before sending
|
||||
// Do NOT create a server chat here. The chat is created on first send
|
||||
// (with server syncing + title generation) in chat_providers.dart.
|
||||
} catch (e) {
|
||||
debugPrint('ShareReceiver: failed to process payload: $e');
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedMessageIds = <String>{};
|
||||
Timer? _scrollDebounceTimer;
|
||||
bool _isDeactivated = false;
|
||||
|
||||
String _formatModelDisplayName(String name) {
|
||||
var display = name.trim();
|
||||
@@ -253,6 +254,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
_isDeactivated = true;
|
||||
_scrollDebounceTimer?.cancel();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
void activate() {
|
||||
super.activate();
|
||||
_isDeactivated = false;
|
||||
}
|
||||
|
||||
void _handleMessageSend(String text, dynamic selectedModel) async {
|
||||
if (selectedModel == null) {
|
||||
return;
|
||||
@@ -460,7 +474,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
if (_scrollDebounceTimer?.isActive == true) return;
|
||||
|
||||
_scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () {
|
||||
if (!mounted || !_scrollController.hasClients) return;
|
||||
if (!mounted || _isDeactivated || !_scrollController.hasClients) return;
|
||||
|
||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||
final currentScroll = _scrollController.position.pixels;
|
||||
@@ -483,7 +497,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
showButton = farFromBottom && maxScroll > showThreshold;
|
||||
}
|
||||
|
||||
if (showButton != _showScrollToBottom && mounted) {
|
||||
if (showButton != _showScrollToBottom && mounted && !_isDeactivated) {
|
||||
setState(() {
|
||||
_showScrollToBottom = showButton;
|
||||
});
|
||||
|
||||
@@ -169,6 +169,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
StreamSubscription<String>? _textSub;
|
||||
int _intensity = 0; // 0..10 from service
|
||||
String _baseTextAtStart = '';
|
||||
bool _isDeactivated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -185,26 +186,26 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Listen for prefilled text updates (e.g., from share intent)
|
||||
// Apply any prefilled text on first frame (focus/expand handled via inputFocusTrigger)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
final text = ref.read(prefilledInputTextProvider);
|
||||
if (text != null && text.isNotEmpty) {
|
||||
_controller.text = text;
|
||||
_controller.selection = TextSelection.collapsed(offset: text.length);
|
||||
// Clear after applying so it doesn't re-apply on rebuilds
|
||||
ref.read(prefilledInputTextProvider.notifier).state = null;
|
||||
_ensureFocusedIfEnabled();
|
||||
if (!_isExpanded) _setExpanded(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Removed ref.listen here; it must be used from build in this Riverpod version
|
||||
|
||||
// Listen for text changes and update only when emptiness flips
|
||||
_controller.addListener(() {
|
||||
final has = _controller.text.trim().isNotEmpty;
|
||||
if (has != _hasText) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
setState(() => _hasText = has);
|
||||
// Intelligent expansion: expand when user starts typing
|
||||
if (has && !_isExpanded) {
|
||||
@@ -220,14 +221,14 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
_blurCollapseTimer?.cancel();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
final hasFocus = _focusNode.hasFocus;
|
||||
if (hasFocus) {
|
||||
if (!_isExpanded) _setExpanded(true);
|
||||
} else {
|
||||
// Defer collapse slightly to avoid IME show/hide race conditions
|
||||
_blurCollapseTimer = Timer(const Duration(milliseconds: 160), () {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
if (_focusNode.hasFocus) return; // focus came back
|
||||
// Collapse only when keyboard is fully hidden to avoid flicker
|
||||
final keyboardVisible =
|
||||
@@ -246,7 +247,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
// The TextField's autofocus: true should handle focus and keyboard automatically
|
||||
// Additionally, request focus after first frame to ensure reliability across platforms
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
if (!_hasAutoFocusedOnce && widget.enabled) {
|
||||
_ensureFocusedIfEnabled();
|
||||
_hasAutoFocusedOnce = true;
|
||||
@@ -271,17 +272,33 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
void _ensureFocusedIfEnabled() {
|
||||
if (!widget.enabled) return;
|
||||
if (!_focusNode.hasFocus) {
|
||||
FocusScope.of(context).requestFocus(_focusNode);
|
||||
// Use FocusNode directly to avoid depending on Inherited widgets
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
_isDeactivated = true;
|
||||
_blurCollapseTimer?.cancel();
|
||||
_expandController.stop();
|
||||
_pulseController.stop();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
void activate() {
|
||||
super.activate();
|
||||
_isDeactivated = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ModernChatInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.enabled && !oldWidget.enabled && !_hasAutoFocusedOnce) {
|
||||
// Became enabled (e.g., after selecting a model) → focus the input
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
_ensureFocusedIfEnabled();
|
||||
_hasAutoFocusedOnce = true;
|
||||
});
|
||||
@@ -289,7 +306,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
if (!widget.enabled && oldWidget.enabled) {
|
||||
// Became disabled → collapse and hide keyboard
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
if (_isExpanded) _setExpanded(false);
|
||||
if (_focusNode.hasFocus) {
|
||||
_focusNode.unfocus();
|
||||
@@ -312,7 +329,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
}
|
||||
|
||||
void _setExpanded(bool expanded) {
|
||||
if (_isExpanded == expanded) return;
|
||||
if (!mounted || _isDeactivated || _isExpanded == expanded) return;
|
||||
setState(() {
|
||||
_isExpanded = expanded;
|
||||
});
|
||||
@@ -325,6 +342,21 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Listen for prefilled text changes safely from build
|
||||
ref.listen<String?>(prefilledInputTextProvider, (previous, next) {
|
||||
final incoming = next?.trim();
|
||||
if (incoming == null || incoming.isEmpty) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _isDeactivated) return;
|
||||
_controller.text = incoming;
|
||||
_controller.selection =
|
||||
TextSelection.collapsed(offset: incoming.length);
|
||||
try {
|
||||
ref.read(prefilledInputTextProvider.notifier).state = null;
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
|
||||
// Check if assistant is currently generating by checking last assistant message streaming
|
||||
final messages = ref.watch(chatMessagesProvider);
|
||||
final isGenerating =
|
||||
@@ -345,7 +377,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
// React to external focus requests (e.g., from share prefill)
|
||||
final focusTick = ref.watch(inputFocusTriggerProvider);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDeactivated) return;
|
||||
if (focusTick > 0) {
|
||||
_ensureFocusedIfEnabled();
|
||||
if (!_isExpanded) _setExpanded(true);
|
||||
|
||||
@@ -375,6 +375,13 @@ class _AnimatedThemeWrapperState extends State<AnimatedThemeWrapper>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
// Pause animations during deactivation to avoid rebuilds in wrong build scope
|
||||
_controller.stop();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
||||
@@ -468,6 +468,13 @@ class _ShimmerLoaderState extends State<ShimmerLoader>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
// Pause shimmer during deactivation to avoid rebuilds in wrong build scope
|
||||
_shimmerController.stop();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
|
||||
@@ -229,6 +229,13 @@ class _SkeletonLoaderState extends State<_SkeletonLoader>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
// Pause shimmer during deactivation to avoid rebuilds in wrong build scope
|
||||
_controller.stop();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
|
||||
@@ -55,6 +55,13 @@ class _SkeletonLoaderState extends State<SkeletonLoader>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
// Pause shimmer during deactivation to avoid rebuilds in wrong build scope
|
||||
_controller.stop();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
||||
Reference in New Issue
Block a user