generated from ttipo/project-base
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:
@@ -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 코드를 저장했어요');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user