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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user