feat: add share image
This commit is contained in:
@@ -1,8 +1,13 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:dio/dio.dart' as dio;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../../auth/providers/unified_auth_providers.dart';
|
import '../../auth/providers/unified_auth_providers.dart';
|
||||||
@@ -276,17 +281,15 @@ class _EnhancedImageAttachmentState
|
|||||||
|
|
||||||
// Apply fade animation only when first showing content
|
// Apply fade animation only when first showing content
|
||||||
if (!widget.disableAnimation && _hasShownContent) {
|
if (!widget.disableAnimation && _hasShownContent) {
|
||||||
return FadeTransition(
|
return FadeTransition(opacity: _fadeAnimation, child: imageWidget);
|
||||||
opacity: _fadeAnimation,
|
|
||||||
child: imageWidget,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageWidget;
|
return imageWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLoadingState() {
|
Widget _buildLoadingState() {
|
||||||
final constraints = widget.constraints ??
|
final constraints =
|
||||||
|
widget.constraints ??
|
||||||
const BoxConstraints(
|
const BoxConstraints(
|
||||||
maxWidth: 300,
|
maxWidth: 300,
|
||||||
maxHeight: 300,
|
maxHeight: 300,
|
||||||
@@ -327,7 +330,9 @@ class _EnhancedImageAttachmentState
|
|||||||
.animate(onPlay: (controller) => controller.repeat())
|
.animate(onPlay: (controller) => controller.repeat())
|
||||||
.shimmer(
|
.shimmer(
|
||||||
duration: const Duration(milliseconds: 1500),
|
duration: const Duration(milliseconds: 1500),
|
||||||
color: context.conduitTheme.shimmerHighlight.withValues(alpha: 0.3),
|
color: context.conduitTheme.shimmerHighlight.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// Progress indicator overlay
|
// Progress indicator overlay
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@@ -342,7 +347,8 @@ class _EnhancedImageAttachmentState
|
|||||||
Widget _buildErrorState() {
|
Widget _buildErrorState() {
|
||||||
return Container(
|
return Container(
|
||||||
key: const ValueKey('error'),
|
key: const ValueKey('error'),
|
||||||
constraints: widget.constraints ??
|
constraints:
|
||||||
|
widget.constraints ??
|
||||||
const BoxConstraints(
|
const BoxConstraints(
|
||||||
maxWidth: 300,
|
maxWidth: 300,
|
||||||
maxHeight: 150,
|
maxHeight: 150,
|
||||||
@@ -382,9 +388,7 @@ class _EnhancedImageAttachmentState
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
).animate().fadeIn(duration: const Duration(milliseconds: 200));
|
||||||
.animate()
|
|
||||||
.fadeIn(duration: const Duration(milliseconds: 200));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNetworkImage() {
|
Widget _buildNetworkImage() {
|
||||||
@@ -396,7 +400,8 @@ class _EnhancedImageAttachmentState
|
|||||||
// Add auth token from unified auth provider
|
// Add auth token from unified auth provider
|
||||||
if (authToken != null && authToken.isNotEmpty) {
|
if (authToken != null && authToken.isNotEmpty) {
|
||||||
headers['Authorization'] = 'Bearer $authToken';
|
headers['Authorization'] = 'Bearer $authToken';
|
||||||
} else if (api?.serverConfig.apiKey != null && api!.serverConfig.apiKey!.isNotEmpty) {
|
} else if (api?.serverConfig.apiKey != null &&
|
||||||
|
api!.serverConfig.apiKey!.isNotEmpty) {
|
||||||
// Fallback to API key from server config
|
// Fallback to API key from server config
|
||||||
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
||||||
}
|
}
|
||||||
@@ -465,11 +470,9 @@ class _EnhancedImageAttachmentState
|
|||||||
|
|
||||||
Widget _wrapImage(Widget imageWidget) {
|
Widget _wrapImage(Widget imageWidget) {
|
||||||
final wrappedImage = Container(
|
final wrappedImage = Container(
|
||||||
constraints: widget.constraints ??
|
constraints:
|
||||||
const BoxConstraints(
|
widget.constraints ??
|
||||||
maxWidth: 400,
|
const BoxConstraints(maxWidth: 400, maxHeight: 400),
|
||||||
maxHeight: 400,
|
|
||||||
),
|
|
||||||
margin: widget.isMarkdownFormat
|
margin: widget.isMarkdownFormat
|
||||||
? const EdgeInsets.symmetric(vertical: Spacing.sm)
|
? const EdgeInsets.symmetric(vertical: Spacing.sm)
|
||||||
: EdgeInsets.zero,
|
: EdgeInsets.zero,
|
||||||
@@ -491,9 +494,16 @@ class _EnhancedImageAttachmentState
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: widget.onTap ?? () => _showFullScreenImage(context),
|
onTap: widget.onTap ?? () => _showFullScreenImage(context),
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: 'image_${widget.attachmentId}_${DateTime.now().millisecondsSinceEpoch}',
|
tag:
|
||||||
flightShuttleBuilder: (flightContext, animation, flightDirection,
|
'image_${widget.attachmentId}_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
fromHeroContext, toHeroContext) {
|
flightShuttleBuilder:
|
||||||
|
(
|
||||||
|
flightContext,
|
||||||
|
animation,
|
||||||
|
flightDirection,
|
||||||
|
fromHeroContext,
|
||||||
|
toHeroContext,
|
||||||
|
) {
|
||||||
final hero = flightDirection == HeroFlightDirection.push
|
final hero = flightDirection == HeroFlightDirection.push
|
||||||
? fromHeroContext.widget as Hero
|
? fromHeroContext.widget as Hero
|
||||||
: toHeroContext.widget as Hero;
|
: toHeroContext.widget as Hero;
|
||||||
@@ -548,7 +558,8 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
// Add auth token from unified auth provider
|
// Add auth token from unified auth provider
|
||||||
if (authToken != null && authToken.isNotEmpty) {
|
if (authToken != null && authToken.isNotEmpty) {
|
||||||
headers['Authorization'] = 'Bearer $authToken';
|
headers['Authorization'] = 'Bearer $authToken';
|
||||||
} else if (api?.serverConfig.apiKey != null && api!.serverConfig.apiKey!.isNotEmpty) {
|
} else if (api?.serverConfig.apiKey != null &&
|
||||||
|
api!.serverConfig.apiKey!.isNotEmpty) {
|
||||||
// Fallback to API key from server config
|
// Fallback to API key from server config
|
||||||
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
||||||
}
|
}
|
||||||
@@ -585,10 +596,7 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
actualBase64 = imageData;
|
actualBase64 = imageData;
|
||||||
}
|
}
|
||||||
final imageBytes = base64.decode(actualBase64);
|
final imageBytes = base64.decode(actualBase64);
|
||||||
imageWidget = Image.memory(
|
imageWidget = Image.memory(imageBytes, fit: BoxFit.contain);
|
||||||
imageBytes,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
imageWidget = Center(
|
imageWidget = Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -617,17 +625,109 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: MediaQuery.of(context).padding.top + 16,
|
top: MediaQuery.of(context).padding.top + 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
child: IconButton(
|
child: Row(
|
||||||
icon: const Icon(
|
mainAxisSize: MainAxisSize.min,
|
||||||
Icons.close,
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Platform.isIOS ? Icons.ios_share : Icons.share_outlined,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 28,
|
size: 26,
|
||||||
),
|
),
|
||||||
|
onPressed: () => _shareImage(context, ref),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white, size: 28),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _shareImage(BuildContext context, WidgetRef ref) async {
|
||||||
|
try {
|
||||||
|
Uint8List bytes;
|
||||||
|
String? fileExtension;
|
||||||
|
|
||||||
|
if (imageData.startsWith('http')) {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
final authToken = ref.read(authTokenProvider3);
|
||||||
|
final headers = <String, String>{};
|
||||||
|
|
||||||
|
if (authToken != null && authToken.isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer $authToken';
|
||||||
|
} else if (api?.serverConfig.apiKey != null &&
|
||||||
|
api!.serverConfig.apiKey!.isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
||||||
|
}
|
||||||
|
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
|
||||||
|
headers.addAll(api.serverConfig.customHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = api?.dio ?? dio.Dio();
|
||||||
|
final response = await client.get<List<int>>(
|
||||||
|
imageData,
|
||||||
|
options: dio.Options(
|
||||||
|
responseType: dio.ResponseType.bytes,
|
||||||
|
headers: headers.isNotEmpty ? headers : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final data = response.data;
|
||||||
|
if (data == null || data.isEmpty) {
|
||||||
|
throw Exception('Empty image data');
|
||||||
|
}
|
||||||
|
bytes = Uint8List.fromList(data);
|
||||||
|
|
||||||
|
final contentType = response.headers.map['content-type']?.first;
|
||||||
|
if (contentType != null && contentType.startsWith('image/')) {
|
||||||
|
fileExtension = contentType.split('/').last;
|
||||||
|
if (fileExtension == 'jpeg') fileExtension = 'jpg';
|
||||||
|
} else {
|
||||||
|
final uri = Uri.tryParse(imageData);
|
||||||
|
final lastSegment = uri?.pathSegments.isNotEmpty == true
|
||||||
|
? uri!.pathSegments.last
|
||||||
|
: '';
|
||||||
|
final dotIndex = lastSegment.lastIndexOf('.');
|
||||||
|
if (dotIndex != -1 && dotIndex < lastSegment.length - 1) {
|
||||||
|
final ext = lastSegment.substring(dotIndex + 1).toLowerCase();
|
||||||
|
if (ext.length <= 5) {
|
||||||
|
fileExtension = ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String actualBase64 = imageData;
|
||||||
|
if (imageData.startsWith('data:')) {
|
||||||
|
final commaIndex = imageData.indexOf(',');
|
||||||
|
final meta = imageData.substring(5, commaIndex); // image/png;base64
|
||||||
|
final slashIdx = meta.indexOf('/');
|
||||||
|
final semicolonIdx = meta.indexOf(';');
|
||||||
|
if (slashIdx != -1 && semicolonIdx != -1 && slashIdx < semicolonIdx) {
|
||||||
|
final subtype = meta.substring(slashIdx + 1, semicolonIdx);
|
||||||
|
fileExtension = subtype == 'jpeg' ? 'jpg' : subtype;
|
||||||
|
}
|
||||||
|
actualBase64 = imageData.substring(commaIndex + 1);
|
||||||
|
}
|
||||||
|
bytes = base64.decode(actualBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileExtension ??= 'png';
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final filePath =
|
||||||
|
'${tempDir.path}/conduit_shared_${DateTime.now().millisecondsSinceEpoch}.$fileExtension';
|
||||||
|
final file = File(filePath);
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
|
||||||
|
await SharePlus.instance.share(ShareParams(files: [XFile(file.path)]));
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Failed to share image')));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
16
pubspec.lock
16
pubspec.lock
@@ -968,6 +968,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.1"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.1.0"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ dependencies:
|
|||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
wakelock_plus: ^1.2.10
|
wakelock_plus: ^1.2.10
|
||||||
|
share_plus: ^11.1.0
|
||||||
|
|
||||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user