diff --git a/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart b/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart index 7af57cdaab..d4d25ca67b 100644 --- a/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart +++ b/mobile/apps/photos/lib/ui/sharing/manage_links_widget.dart @@ -7,6 +7,7 @@ import "package:flutter/services.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/public_url.dart"; import 'package:photos/models/collection/collection.dart'; +import 'package:photos/service_locator.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/theme/colors.dart'; import 'package:photos/theme/ente_theme.dart'; @@ -19,6 +20,7 @@ import "package:photos/ui/components/toggle_switch_widget.dart"; import 'package:photos/ui/notification/toast.dart'; import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart'; import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart'; +import 'package:photos/ui/sharing/qr_code_dialog_widget.dart'; import 'package:photos/utils/dialog_util.dart'; import 'package:photos/utils/navigation_util.dart'; import "package:photos/utils/share_util.dart"; @@ -291,6 +293,32 @@ class _ManageSharedLinkWidgetState extends State { ); }, isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: flagService.internalUser, + ), + if (!url.isExpired && flagService.internalUser) + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + if (!url.isExpired && flagService.internalUser) + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Send QR Code (i)", + makeTextBold: true, + ), + leadingIcon: Icons.qr_code_outlined, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + await showDialog( + context: context, + builder: (BuildContext context) { + return QrCodeDialogWidget( + collection: widget.collection!, + ); + }, + ); + }, + isTopBorderRadiusRemoved: true, ), const SizedBox( height: 24, diff --git a/mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart b/mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart new file mode 100644 index 0000000000..e5f53f3467 --- /dev/null +++ b/mobile/apps/photos/lib/ui/sharing/qr_code_dialog_widget.dart @@ -0,0 +1,211 @@ +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photos/models/collection/collection.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/theme/ente_theme.dart'; +import 'package:photos/ui/components/buttons/button_widget.dart'; +import 'package:photos/ui/components/models/button_type.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; + +class QrCodeDialogWidget extends StatefulWidget { + final Collection collection; + + const QrCodeDialogWidget({ + super.key, + required this.collection, + }); + + @override + State createState() => _QrCodeDialogWidgetState(); +} + +class _QrCodeDialogWidgetState extends State { + final GlobalKey _qrKey = GlobalKey(); + + Future _shareQrCode() async { + try { + final RenderRepaintBoundary boundary = + _qrKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final ui.Image image = await boundary.toImage(pixelRatio: 3.0); + final ByteData? byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData != null) { + final Uint8List pngBytes = byteData.buffer.asUint8List(); + + final directory = await getTemporaryDirectory(); + final file = File( + '${directory.path}/ente_qr_${widget.collection.displayName}.png', + ); + await file.writeAsBytes(pngBytes); + + await Share.shareXFiles( + [XFile(file.path)], + text: + 'Scan this QR code to view my ${widget.collection.displayName} album on ente', + ); + + // Close the dialog after sharing is initiated + if (mounted) { + Navigator.of(context).pop(); + } + } + } catch (e) { + debugPrint('Error sharing QR code: $e'); + } + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final double qrSize = min(screenWidth - 80, 300.0); + final enteTextTheme = getEnteTextTheme(context); + final enteColorScheme = getEnteColorScheme(context); + + // Get the public URL for the collection + final String publicUrl = + CollectionsService.instance.getPublicUrl(widget.collection); + + // Get album name, truncate if too long + final String albumName = widget.collection.displayName.length > 30 + ? '${widget.collection.displayName.substring(0, 27)}...' + : widget.collection.displayName; + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: enteColorScheme.backgroundBase, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with close button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "QR Code", + style: enteTextTheme.largeBold, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + color: enteColorScheme.strokeBase, + ), + ], + ), + const SizedBox(height: 8), + + // QR Code with RepaintBoundary for sharing + RepaintBoundary( + key: _qrKey, + child: Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.grey.shade200, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Album name at top center (inside border) - Reduced size + Text( + albumName, + style: enteTextTheme.bodyBold.copyWith( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + + // QR Code with better spacing + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey.shade100, + width: 1, + ), + ), + child: QrImageView( + data: publicUrl, + version: QrVersions.auto, + size: qrSize - 100, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + errorCorrectionLevel: QrErrorCorrectLevel.M, + ), + ), + const SizedBox(height: 24), + ], + ), + + // Ente branding at bottom right (inside border) - Fixed positioning + Positioned( + bottom: -2, + right: 2, + child: Text( + 'ente', + style: enteTextTheme.small.copyWith( + color: enteColorScheme.primary700, + fontSize: 14, + letterSpacing: 1.2, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Share button + ButtonWidget( + buttonType: ButtonType.primary, + icon: Icons.adaptive.share, + labelText: "Share", + onTap: _shareQrCode, + shouldSurfaceExecutionStates: false, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart b/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart index 1f9931e612..c9608a2367 100644 --- a/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart +++ b/mobile/apps/photos/lib/ui/sharing/share_collection_page.dart @@ -1,10 +1,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import "package:photos/extensions/user_extension.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/models/api/collection/user.dart"; import 'package:photos/models/collection/collection.dart'; +import 'package:photos/service_locator.dart'; import 'package:photos/services/collections_service.dart'; import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; @@ -19,6 +21,7 @@ import 'package:photos/ui/sharing/album_participants_page.dart'; import "package:photos/ui/sharing/album_share_info_widget.dart"; import "package:photos/ui/sharing/manage_album_participant.dart"; import 'package:photos/ui/sharing/manage_links_widget.dart'; +import 'package:photos/ui/sharing/qr_code_dialog_widget.dart'; import 'package:photos/ui/sharing/user_avator_widget.dart'; import 'package:photos/utils/navigation_util.dart'; import 'package:photos/utils/share_util.dart'; @@ -214,8 +217,34 @@ class _ShareCollectionPageState extends State { ); }, isTopBorderRadiusRemoved: true, - isBottomBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: flagService.internalUser, ), + if (flagService.internalUser) + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + if (flagService.internalUser) + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Send QR Code (i)", + makeTextBold: true, + ), + leadingIcon: Icons.qr_code_outlined, + menuItemColor: getEnteColorScheme(context).fillFaint, + onTap: () async { + await showDialog( + context: context, + builder: (BuildContext context) { + return QrCodeDialogWidget( + collection: widget.collection, + ); + }, + ); + }, + isTopBorderRadiusRemoved: true, + isBottomBorderRadiusRemoved: true, + ), ], ); } diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index a544e603c2..f78a4449c8 100644 --- a/mobile/apps/photos/pubspec.yaml +++ b/mobile/apps/photos/pubspec.yaml @@ -177,6 +177,7 @@ dependencies: url: https://github.com/ente-io/privacy_screen.git ref: v2-only pro_image_editor: ^6.0.0 + qr_flutter: ^4.1.0 receive_sharing_intent: # pub.dev is behind git: url: https://github.com/KasemJaffer/receive_sharing_intent.git