diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index c4346bb..594262a 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,11 @@ + + + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Tma App + NSPhotoLibraryAddUsageDescription + 펫 명함 이미지를 사진 보관함에 저장합니다. NSPhotoLibraryUsageDescription 우리 아이 프로필 사진을 등록하기 위해 사진 보관함에 접근합니다. CFBundleExecutable diff --git a/flutter/lib/core/storage/album_saver.dart b/flutter/lib/core/storage/album_saver.dart new file mode 100644 index 0000000..0b46160 --- /dev/null +++ b/flutter/lib/core/storage/album_saver.dart @@ -0,0 +1,34 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gal/gal.dart'; +import 'package:share_plus/share_plus.dart'; + +/// 이미지를 기기 앨범(사진 보관함)에 저장한다 — 테스트에서 바꿔 끼울 수 있게 계약으로 둔다 +abstract interface class AlbumSaver { + /// 저장했으면 true — 권한을 거절했거나 실패하면 false + Future savePng(Uint8List bytes, String name); +} + +/// gal 로 앨범에 저장한다. 웹에는 앨범이 없어 파일 내려받기(공유)로 대신한다 +class GalAlbumSaver implements AlbumSaver { + const GalAlbumSaver(); + + @override + Future savePng(Uint8List bytes, String name) async { + if (kIsWeb) { + await SharePlus.instance.share( + ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: '$name.png')]), + ); + return true; + } + try { + if (!await Gal.hasAccess() && !await Gal.requestAccess()) return false; + await Gal.putImageBytes(bytes, name: name); + return true; + } on GalException { + return false; + } + } +} + +final albumSaverProvider = Provider((ref) => const GalAlbumSaver()); diff --git a/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart b/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart index fed7b1f..d0cb880 100644 --- a/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart +++ b/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart @@ -4,13 +4,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/storage/album_saver.dart'; import 'package:tma_app/core/theme/app_shadows.dart'; import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart'; +import 'package:tma_app/shared/widgets/blurred_modal.dart'; import 'package:tma_app/shared/widgets/capture_boundary.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/pressable.dart'; import 'package:tma_app/shared/widgets/rup_button.dart'; import 'package:tma_app/shared/widgets/rup_sheet.dart'; import 'package:tma_app/shared/widgets/rup_toast.dart'; @@ -42,10 +44,24 @@ Future showPetCardShareSheet( ); } -Future _sharePng(Uint8List bytes, String fileName) async { - await SharePlus.instance.share( - ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: fileName)]), - ); +/// 앨범에 저장하지 못했을 때 (권한 거절 등) +const String _kSaveFailed = '저장하지 못했어요. 사진 접근 권한을 확인해 주세요'; + +/// 앨범에 저장하고 결과를 알린다 — 시안: 시트를 닫고 "…저장했어요". +/// 시트를 닫은 뒤에 불리므로 저장기·토스트는 닫기 전에 꺼내 넘긴다 +Future _saveToAlbum( + AlbumSaver saver, + ToastNotifier toast, + Uint8List? bytes, + String name, + String done, +) async { + final saved = bytes != null && await saver.savePng(bytes, name); + if (saved) { + toast.show(done, icon: RupIconName.download); + } else { + toast.show(_kSaveFailed, icon: RupIconName.alert); + } } class _ShareBody extends ConsumerWidget { @@ -61,16 +77,18 @@ class _ShareBody extends ConsumerWidget { final textTheme = Theme.of(context).textTheme; final toast = ref.read(toastProvider.notifier); + final saver = ref.read(albumSaverProvider); + Future saveImage() async { final bytes = await capture(); if (context.mounted) Navigator.of(context).pop(); - if (bytes != null) await _sharePng(bytes, 'rup_${petName}_명함.png'); + await _saveToAlbum(saver, toast, bytes, 'rup_${petName}_명함', '이미지로 저장했어요'); } Future copyCode() async { await Clipboard.setData(ClipboardData(text: code)); if (context.mounted) Navigator.of(context).pop(); - toast.show('명함 코드를 복사했어요', icon: RupIconName.link); + toast.show('코드 $code 을 복사했어요.', icon: RupIconName.link); } return Column( @@ -138,7 +156,7 @@ class _ShareBody extends ConsumerWidget { label: 'QR 코드\n공유', desc: '스캔 한 번', tint: const Color(0xFF9B6BE8), - onTap: () => unawaited(_showQr(context, petName: petName, code: code)), + onTap: () => unawaited(_showQr(context, saver, toast, petName: petName, code: code)), ), ), ], @@ -166,48 +184,45 @@ class _ShareTile extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - return Semantics( - button: true, - label: label.replaceAll('\n', ' '), - excludeSemantics: true, - child: GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.fromLTRB(5, 20, 5, 16), - decoration: BoxDecoration( - color: Color.alphaBlend(tint.withValues(alpha: .08), AppColors.surface), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: tint.withValues(alpha: .22)), - ), - child: Column( - children: [ - Container( - width: 54, - height: 54, - decoration: BoxDecoration( - color: tint, - borderRadius: BorderRadius.circular(17), - boxShadow: [BoxShadow(color: tint.withValues(alpha: .3), offset: const Offset(0, 3), blurRadius: 8)], - ), - alignment: Alignment.center, - child: icon == RupIconName.qrcode - ? const RupGlyphIcon(RupGlyph.qr, size: 26, color: Colors.white) - : RupIcon(icon, size: 26, stroke: 2, color: Colors.white), + // 시안 .rup-share-tile:active — scale(.95) + return Pressable( + onTap: onTap, + semanticLabel: label.replaceAll('\n', ' '), + child: Container( + padding: const EdgeInsets.fromLTRB(5, 20, 5, 16), + decoration: BoxDecoration( + color: Color.alphaBlend(tint.withValues(alpha: .08), AppColors.surface), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: tint.withValues(alpha: .22)), + ), + child: Column( + children: [ + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: tint, + borderRadius: BorderRadius.circular(17), + boxShadow: [BoxShadow(color: tint.withValues(alpha: .3), offset: const Offset(0, 3), blurRadius: 8)], ), - const SizedBox(height: 17), - Text( - label, - textAlign: TextAlign.center, - style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: 1.22), - ), - const SizedBox(height: 8), - Text( - desc, - maxLines: 1, - style: textTheme.bodySmall!.copyWith(fontSize: 10.5, color: AppColors.muted, height: 1.3), - ), - ], - ), + alignment: Alignment.center, + child: icon == RupIconName.qrcode + ? const RupGlyphIcon(RupGlyph.qr, size: 26, color: Colors.white) + : RupIcon(icon, size: 26, stroke: 2, color: Colors.white), + ), + const SizedBox(height: 17), + Text( + label, + textAlign: TextAlign.center, + style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: 1.22), + ), + const SizedBox(height: 8), + Text( + desc, + maxLines: 1, + style: textTheme.bodySmall!.copyWith(fontSize: 10.5, color: AppColors.muted, height: 1.3), + ), + ], ), ), ); @@ -215,12 +230,17 @@ class _ShareTile extends StatelessWidget { } /// 펫 명함 QR — 가운데 명함 표시가 있는 QR + 이미지 저장 -Future _showQr(BuildContext context, {required String petName, required String code}) { +Future _showQr( + BuildContext context, + AlbumSaver saver, + ToastNotifier toast, { + required String petName, + required String code, +}) { final capture = CaptureController(); - return showDialog( - context: context, - useRootNavigator: true, - barrierColor: AppColors.scrim, + final sheet = Navigator.of(context); + return showBlurredDialog( + context, builder: (context) { final textTheme = Theme.of(context).textTheme; return Dialog( @@ -234,6 +254,7 @@ Future _showQr(BuildContext context, {required String petName, required St decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all(color: AppColors.line2), boxShadow: AppShadows.sm, ), child: Stack( @@ -299,8 +320,10 @@ Future _showQr(BuildContext context, {required String petName, required St leading: const RupIcon(RupIconName.download, size: 18, stroke: 2.1, color: Colors.white), onPressed: () async { final bytes = await capture.capturePng(); + // 시안: QR 창과 공유 시트를 함께 닫는다 if (context.mounted) Navigator.of(context).pop(); - if (bytes != null) await _sharePng(bytes, 'rup_${petName}_QR.png'); + if (sheet.mounted) sheet.pop(); + await _saveToAlbum(saver, toast, bytes, 'rup_${petName}_QR', 'QR 코드를 저장했어요'); }, ), ], diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index e6c891e..e1b46d3 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import file_selector_macos import flutter_secure_storage_darwin +import gal import share_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 4a3e2a4..1af1e1c 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -320,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: f71e79840fe023a21f2f949771375444b6efcd34b9e625d5f4f5504971380a77 + url: "https://pub.dev" + source: hosted + version: "2.3.3" glob: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 10fb933..19009c7 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: path_provider: ^2.1.6 share_plus: ^13.3.0 qr_flutter: ^4.1.0 + gal: ^2.3.3 dev_dependencies: flutter_test: diff --git a/flutter/test/pet_card_share_test.dart b/flutter/test/pet_card_share_test.dart new file mode 100644 index 0000000..a73890f --- /dev/null +++ b/flutter/test/pet_card_share_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/core/storage/album_saver.dart'; +import 'package:tma_app/features/pet_card/domain/card_style.dart'; +import 'package:tma_app/features/pet_card/domain/pet_card.dart'; + +import 'support/app_harness.dart'; + +/// 앨범 대신 받은 이름만 모아 두는 저장기 +class _FakeAlbum implements AlbumSaver { + _FakeAlbum({this.ok = true}); + + final bool ok; + final saved = []; + + @override + Future savePng(Uint8List bytes, String name) async { + saved.add(name); + return ok; + } +} + +Map _prefs() => { + 'pets.v1': jsonEncode([samplePet().toJson()]), + 'petCards.v1': jsonEncode([PetCard(id: 'c1', petId: 'pet-1', style: CardStyle.initial()).toJson()]), + }; + +Future<_FakeAlbum> _openShare(WidgetTester tester, {bool ok = true}) async { + final album = _FakeAlbum(ok: ok); + await launchSignedIn(tester, extra: _prefs(), overrides: [albumSaverProvider.overrideWithValue(album)]); + await tester.tap(find.bySemanticsLabel('콩이 명함 공유')); + await settle(tester); + expect(find.text('콩이 펫 명함 공유'), findsOneWidget); + return album; +} + +/// 명함 이미지를 떠 내는 일은 실제 비동기라 runAsync 안에서 누른다 +Future _tapAndWait(WidgetTester tester, Finder finder) async { + await tester.runAsync(() async { + await tester.tap(finder); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await settle(tester); +} + +void main() { + testWidgets('이미지로 저장하면 앨범에 저장하고 시트를 닫은 뒤 알린다', (tester) async { + final album = await _openShare(tester); + await _tapAndWait(tester, find.bySemanticsLabel('이미지로 저장')); + + expect(album.saved, ['rup_콩이_명함']); + expect(find.text('콩이 펫 명함 공유'), findsNothing); + expect(find.text('이미지로 저장했어요'), findsOneWidget); + }); + + testWidgets('QR 이미지 저장은 QR 창과 공유 시트를 함께 닫는다', (tester) async { + final album = await _openShare(tester); + await tester.tap(find.bySemanticsLabel('QR 코드 공유')); + await settle(tester); + expect(find.text('펫 명함 QR'), findsOneWidget); + + await _tapAndWait(tester, find.text('QR 이미지 저장')); + expect(album.saved, ['rup_콩이_QR']); + expect(find.text('펫 명함 QR'), findsNothing); + expect(find.text('콩이 펫 명함 공유'), findsNothing); + expect(find.text('QR 코드를 저장했어요'), findsOneWidget); + }); + + testWidgets('사진 접근을 거절해 저장하지 못하면 알려 준다', (tester) async { + await _openShare(tester, ok: false); + await _tapAndWait(tester, find.bySemanticsLabel('이미지로 저장')); + expect(find.text('저장하지 못했어요. 사진 접근 권한을 확인해 주세요'), findsOneWidget); + }); +} diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index 23bcdc8..6a80e84 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index bb6d400..8609f22 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_secure_storage_windows + gal share_plus url_launcher_windows )