feat(share_extension): integrate share_handler package and implement share functionality with updated permissions and entitlements

This commit is contained in:
cogwheel0
2025-08-28 12:59:48 +05:30
parent fc4b10b1f2
commit 4a524d404e
15 changed files with 676 additions and 26 deletions

View File

@@ -0,0 +1,172 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_handler/share_handler.dart' as sh;
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';
/// Lightweight payload for a share event
class SharedPayload {
final String? text;
final List<String> filePaths;
const SharedPayload({this.text, this.filePaths = const []});
bool get hasAnything =>
(text != null && text!.trim().isNotEmpty) || filePaths.isNotEmpty;
}
/// Holds a pending shared payload until the app is ready (e.g., authed + model loaded)
final pendingSharedPayloadProvider = StateProvider<SharedPayload?>((_) => null);
/// Initializes listening to OS share intents and handles them
final shareReceiverInitializerProvider = Provider<void>((ref) {
// Do nothing on web/desktop
if (kIsWeb) return;
final sub = StreamController<SharedPayload>.broadcast();
// 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);
if (pending != null &&
pending.hasAnything &&
navState == AuthNavigationState.authenticated &&
model != null) {
_processPayload(ref, pending);
ref.read(pendingSharedPayloadProvider.notifier).state = null;
}
}
// React when auth/model changes to process a queued share
ref.listen<AuthNavigationState>(
authNavigationStateProvider,
(_, __) => maybeProcessPending(),
);
ref.listen(selectedModelProvider, (_, __) => maybeProcessPending());
// Hook into share_handler
final handler = sh.ShareHandler.instance;
// Handle initial share when app is cold-started via Share
Future.microtask(() async {
try {
final dynamic media = await handler.getInitialSharedMedia();
final payload = _toPayload(media);
if (payload.hasAnything) {
ref.read(pendingSharedPayloadProvider.notifier).state = payload;
maybeProcessPending();
}
} catch (e) {
debugPrint('ShareReceiver: failed to get initial shared media: $e');
}
});
// Handle subsequent shares while app is alive
final streamSub = handler.sharedMediaStream.listen((dynamic media) {
try {
final payload = _toPayload(media);
if (payload.hasAnything) {
ref.read(pendingSharedPayloadProvider.notifier).state = payload;
maybeProcessPending();
}
} catch (e) {
debugPrint('ShareReceiver: failed to parse shared media: $e');
}
});
// Ensure cleanup
ref.onDispose(() async {
await streamSub.cancel();
await sub.close();
});
});
SharedPayload _toPayload(dynamic media) {
if (media == null) return const SharedPayload();
String? text;
final filePaths = <String>[];
try {
// Common field in share_handler: `content` (String?)
text = (media as dynamic).content as String?;
} catch (_) {
try {
// Some plugins use `text`
text = (media as dynamic).text as String?;
} catch (_) {}
}
try {
final list = (media as dynamic).attachments as List<dynamic>?;
if (list != null) {
for (final att in list) {
try {
final p = (att as dynamic).path as String?;
if (p != null && p.isNotEmpty) filePaths.add(p);
} catch (_) {
// Ignore a malformed entry
}
}
}
} catch (_) {
// Older plugins may call it files
try {
final list = (media as dynamic).files as List<dynamic>?;
if (list != null) {
for (final att in list) {
try {
final p = (att as dynamic).path as String?;
if (p != null && p.isNotEmpty) filePaths.add(p);
} catch (_) {}
}
}
} catch (_) {}
}
return SharedPayload(text: text, filePaths: filePaths);
}
Future<void> _processPayload(Ref ref, SharedPayload payload) async {
try {
// Start a fresh chat context but do NOT auto-send
startNewChat(ref);
// Prefer attaching files to the composer so user can add text before sending
if (payload.filePaths.isNotEmpty) {
final svc = ref.read(fileAttachmentServiceProvider);
if (svc != null) {
// Add files to attachment list and kick off uploads, mirroring UI flow
final files = payload.filePaths.map((p) => File(p)).toList();
if (files.isNotEmpty) {
ref.read(attachedFilesProvider.notifier).addFiles(files);
for (final file in files) {
final uploadStream = svc.uploadFile(file);
uploadStream.listen((state) {
ref
.read(attachedFilesProvider.notifier)
.updateFileState(file.path, state);
}, onError: (_) {});
}
}
}
}
// Prefill text in the composer (do not auto-send)
final text = payload.text?.trim();
if (text != null && text.isNotEmpty) {
ref.read(prefilledInputTextProvider.notifier).state = text;
}
// This allows the user to add a caption before sending
} catch (e) {
debugPrint('ShareReceiver: failed to process payload: $e');
}
}

View File

@@ -21,6 +21,9 @@ final chatMessagesProvider =
// Loading state for conversation (used to show chat skeletons during fetch)
final isLoadingConversationProvider = StateProvider<bool>((ref) => false);
// Prefilled input text (e.g., when sharing text from other apps)
final prefilledInputTextProvider = StateProvider<String?>((ref) => null);
class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
final Ref _ref;
StreamSubscription? _messageStream;
@@ -453,6 +456,16 @@ Future<void> sendMessage(
await _sendMessageInternal(ref, message, attachments, toolIds);
}
// Service-friendly wrapper (accepts generic Ref)
Future<void> sendMessageFromService(
Ref ref,
String message,
List<String>? attachments, [
List<String>? toolIds,
]) async {
await _sendMessageInternal(ref, message, attachments, toolIds);
}
// Internal send message implementation
Future<void> _sendMessageInternal(
dynamic ref,

View File

@@ -185,6 +185,20 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
vsync: this,
);
// Listen for prefilled text updates (e.g., from share intent)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) 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);
}
});
// Listen for text changes and update only when emptiness flips
_controller.addListener(() {
final has = _controller.text.trim().isNotEmpty;
@@ -878,8 +892,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
color: isActive
? context.conduitTheme.buttonPrimary
: showBackground
? context.conduitTheme.cardBorder
: Colors.transparent,
? context.conduitTheme.cardBorder
: Colors.transparent,
width: BorderWidth.regular,
),
),
@@ -898,8 +912,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
color: isActive
? context.conduitTheme.buttonPrimary
: showBackground
? context.conduitTheme.cardBackground
: Colors.transparent,
? context.conduitTheme.cardBackground
: Colors.transparent,
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
boxShadow: (isActive || showBackground)
? ConduitShadows.button
@@ -910,11 +924,13 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
size: iconSize ?? IconSize.medium,
color: widget.enabled
? (isActive
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary
.withValues(alpha: Alpha.strong))
: context.conduitTheme.textPrimary
.withValues(alpha: Alpha.disabled),
? context.conduitTheme.buttonPrimaryText
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.strong,
))
: context.conduitTheme.textPrimary.withValues(
alpha: Alpha.disabled,
),
),
),
),
@@ -954,8 +970,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
decoration: BoxDecoration(
// Subtle primary tint when active for clearer affordance
color: isActive
? context.conduitTheme.buttonPrimary
.withValues(alpha: Alpha.buttonHover + 0.04)
? context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.buttonHover + 0.04,
)
: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
// No elevation to match modal chips

View File

@@ -18,6 +18,7 @@ import 'features/onboarding/views/onboarding_sheet.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'features/chat/views/chat_page.dart';
import 'features/navigation/views/splash_launcher_page.dart';
import 'core/services/share_receiver_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -66,6 +67,9 @@ class _ConduitAppState extends ConsumerState<ConduitApp> {
// Ensure API service auth integration is active
ref.read(authApiIntegrationProvider);
// Initialize OS share receiver so users can share text/files to Conduit
ref.read(shareReceiverInitializerProvider);
}
@override