feat: add share image
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.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 '../../../core/providers/app_providers.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
@@ -276,17 +281,15 @@ class _EnhancedImageAttachmentState
|
||||
|
||||
// Apply fade animation only when first showing content
|
||||
if (!widget.disableAnimation && _hasShownContent) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: imageWidget,
|
||||
);
|
||||
return FadeTransition(opacity: _fadeAnimation, child: imageWidget);
|
||||
}
|
||||
|
||||
return imageWidget;
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
final constraints = widget.constraints ??
|
||||
final constraints =
|
||||
widget.constraints ??
|
||||
const BoxConstraints(
|
||||
maxWidth: 300,
|
||||
maxHeight: 300,
|
||||
@@ -311,24 +314,26 @@ class _EnhancedImageAttachmentState
|
||||
children: [
|
||||
// Shimmer effect placeholder
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
context.conduitTheme.shimmerBase,
|
||||
context.conduitTheme.shimmerHighlight,
|
||||
context.conduitTheme.shimmerBase,
|
||||
],
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
context.conduitTheme.shimmerBase,
|
||||
context.conduitTheme.shimmerHighlight,
|
||||
context.conduitTheme.shimmerBase,
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.shimmer(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
color: context.conduitTheme.shimmerHighlight.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.animate(onPlay: (controller) => controller.repeat())
|
||||
.shimmer(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
color: context.conduitTheme.shimmerHighlight.withValues(alpha: 0.3),
|
||||
),
|
||||
// Progress indicator overlay
|
||||
CircularProgressIndicator(
|
||||
color: context.conduitTheme.buttonPrimary,
|
||||
@@ -342,7 +347,8 @@ class _EnhancedImageAttachmentState
|
||||
Widget _buildErrorState() {
|
||||
return Container(
|
||||
key: const ValueKey('error'),
|
||||
constraints: widget.constraints ??
|
||||
constraints:
|
||||
widget.constraints ??
|
||||
const BoxConstraints(
|
||||
maxWidth: 300,
|
||||
maxHeight: 150,
|
||||
@@ -382,9 +388,7 @@ class _EnhancedImageAttachmentState
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: const Duration(milliseconds: 200));
|
||||
).animate().fadeIn(duration: const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Widget _buildNetworkImage() {
|
||||
@@ -396,7 +400,8 @@ class _EnhancedImageAttachmentState
|
||||
// Add auth token from unified auth provider
|
||||
if (authToken != null && authToken.isNotEmpty) {
|
||||
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
|
||||
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
||||
}
|
||||
@@ -465,11 +470,9 @@ class _EnhancedImageAttachmentState
|
||||
|
||||
Widget _wrapImage(Widget imageWidget) {
|
||||
final wrappedImage = Container(
|
||||
constraints: widget.constraints ??
|
||||
const BoxConstraints(
|
||||
maxWidth: 400,
|
||||
maxHeight: 400,
|
||||
),
|
||||
constraints:
|
||||
widget.constraints ??
|
||||
const BoxConstraints(maxWidth: 400, maxHeight: 400),
|
||||
margin: widget.isMarkdownFormat
|
||||
? const EdgeInsets.symmetric(vertical: Spacing.sm)
|
||||
: EdgeInsets.zero,
|
||||
@@ -491,17 +494,24 @@ class _EnhancedImageAttachmentState
|
||||
child: InkWell(
|
||||
onTap: widget.onTap ?? () => _showFullScreenImage(context),
|
||||
child: Hero(
|
||||
tag: 'image_${widget.attachmentId}_${DateTime.now().millisecondsSinceEpoch}',
|
||||
flightShuttleBuilder: (flightContext, animation, flightDirection,
|
||||
fromHeroContext, toHeroContext) {
|
||||
final hero = flightDirection == HeroFlightDirection.push
|
||||
? fromHeroContext.widget as Hero
|
||||
: toHeroContext.widget as Hero;
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: hero.child,
|
||||
);
|
||||
},
|
||||
tag:
|
||||
'image_${widget.attachmentId}_${DateTime.now().millisecondsSinceEpoch}',
|
||||
flightShuttleBuilder:
|
||||
(
|
||||
flightContext,
|
||||
animation,
|
||||
flightDirection,
|
||||
fromHeroContext,
|
||||
toHeroContext,
|
||||
) {
|
||||
final hero = flightDirection == HeroFlightDirection.push
|
||||
? fromHeroContext.widget as Hero
|
||||
: toHeroContext.widget as Hero;
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: hero.child,
|
||||
);
|
||||
},
|
||||
child: imageWidget,
|
||||
),
|
||||
),
|
||||
@@ -548,7 +558,8 @@ class FullScreenImageViewer extends ConsumerWidget {
|
||||
// Add auth token from unified auth provider
|
||||
if (authToken != null && authToken.isNotEmpty) {
|
||||
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
|
||||
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
||||
}
|
||||
@@ -585,10 +596,7 @@ class FullScreenImageViewer extends ConsumerWidget {
|
||||
actualBase64 = imageData;
|
||||
}
|
||||
final imageBytes = base64.decode(actualBase64);
|
||||
imageWidget = Image.memory(
|
||||
imageBytes,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
imageWidget = Image.memory(imageBytes, fit: BoxFit.contain);
|
||||
} catch (e) {
|
||||
imageWidget = Center(
|
||||
child: Icon(
|
||||
@@ -617,17 +625,109 @@ class FullScreenImageViewer extends ConsumerWidget {
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 16,
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? Icons.ios_share : Icons.share_outlined,
|
||||
color: Colors.white,
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -53,6 +53,7 @@ dependencies:
|
||||
json_annotation: ^4.9.0
|
||||
google_fonts: ^6.2.1
|
||||
wakelock_plus: ^1.2.10
|
||||
share_plus: ^11.1.0
|
||||
|
||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user