fix: text sharing

This commit is contained in:
cogwheel0
2025-08-28 18:54:06 +05:30
parent 5c72537932
commit 6427caaa5d
8 changed files with 122 additions and 57 deletions

View File

@@ -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}',
);
}
});
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
// Update selection immediately inside provider context
if (!ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).state = modelToSet;
foundation.debugPrint('DEBUG: Set default model: ${modelToSet.name}');
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}',
);
}
});
return fallbackModel;
}
} catch (fallbackError) {

View File

@@ -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');
}

View File

@@ -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;
});

View File

@@ -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);

View File

@@ -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(

View File

@@ -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;

View File

@@ -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(

View File

@@ -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(