feat(flutter): 펫 명함 이미지를 앨범에 저장

공유 시트의 '이미지로 저장'과 QR 창의 'QR 이미지 저장'이 시안처럼 휴대폰 앨범에 저장한다.
지금까지는 공유 창을 띄웠다. 웹은 앨범이 없으므로 공유 창을 그대로 쓴다.

- 저장은 AlbumSaver 뒤에 두고(gal), 테스트에서는 가짜 저장기로 바꿔 끼운다.
- 사진 접근을 거절해 저장하지 못하면 권한을 확인하라고 알린다.
- QR 창은 배경 흐림·테두리를 시안대로, 저장하면 QR 창과 공유 시트를 함께 닫는다.
- 코드 복사 안내 문구를 시안에 맞췄다.

권한: Android 는 10 미만에서만 저장 권한을 요청하고(maxSdkVersion 29), iOS 는 사진 추가 안내 문구를 넣었다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 01:24:24 +09:00
parent 4173c64af8
commit e6f5cd0720
10 changed files with 209 additions and 56 deletions
@@ -1,8 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 명함 이미지를 앨범에 저장 (Android 10 이하만 필요 — gal) -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<application
android:label="tma_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
+2
View File
@@ -8,6 +8,8 @@
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Tma App</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>펫 명함 이미지를 사진 보관함에 저장합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>우리 아이 프로필 사진을 등록하기 위해 사진 보관함에 접근합니다.</string>
<key>CFBundleExecutable</key>
+34
View File
@@ -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<bool> savePng(Uint8List bytes, String name);
}
/// gal 로 앨범에 저장한다. 웹에는 앨범이 없어 파일 내려받기(공유)로 대신한다
class GalAlbumSaver implements AlbumSaver {
const GalAlbumSaver();
@override
Future<bool> 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<AlbumSaver>((ref) => const GalAlbumSaver());
@@ -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<void> showPetCardShareSheet(
);
}
Future<void> _sharePng(Uint8List bytes, String fileName) async {
await SharePlus.instance.share(
ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: fileName)]),
);
/// 앨범에 저장하지 못했을 때 (권한 거절 등)
const String _kSaveFailed = '저장하지 못했어요. 사진 접근 권한을 확인해 주세요';
/// 앨범에 저장하고 결과를 알린다 — 시안: 시트를 닫고 "…저장했어요".
/// 시트를 닫은 뒤에 불리므로 저장기·토스트는 닫기 전에 꺼내 넘긴다
Future<void> _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<void> 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<void> 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<void> _showQr(BuildContext context, {required String petName, required String code}) {
Future<void> _showQr(
BuildContext context,
AlbumSaver saver,
ToastNotifier toast, {
required String petName,
required String code,
}) {
final capture = CaptureController();
return showDialog<void>(
context: context,
useRootNavigator: true,
barrierColor: AppColors.scrim,
final sheet = Navigator.of(context);
return showBlurredDialog<void>(
context,
builder: (context) {
final textTheme = Theme.of(context).textTheme;
return Dialog(
@@ -234,6 +254,7 @@ Future<void> _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<void> _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 코드를 저장했어요');
},
),
],
@@ -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"))
}
+8
View File
@@ -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:
+1
View File
@@ -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:
+76
View File
@@ -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 = <String>[];
@override
Future<bool> savePng(Uint8List bytes, String name) async {
saved.add(name);
return ok;
}
}
Map<String, Object> _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<void> _tapAndWait(WidgetTester tester, Finder finder) async {
await tester.runAsync(() async {
await tester.tap(finder);
await Future<void>.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);
});
}
@@ -8,6 +8,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <gal/gal_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -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(
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
gal
share_plus
url_launcher_windows
)