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
@@ -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 코드를 저장했어요');
},
),
],