diff --git a/toondo/packages/presentation/lib/designsystem/components/ads/app_ad_banner.dart b/toondo/packages/presentation/lib/designsystem/components/ads/app_ad_banner.dart new file mode 100644 index 00000000..7377fd69 --- /dev/null +++ b/toondo/packages/presentation/lib/designsystem/components/ads/app_ad_banner.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:presentation/designsystem/colors/app_colors.dart'; +import 'package:presentation/designsystem/dimensions/app_dimensions.dart'; +import 'package:presentation/designsystem/spacing/app_spacing.dart'; +import 'package:presentation/designsystem/typography/app_typography.dart'; + +class AdBannerContent { + final String appName; + final String ctaLabel; + final ImageProvider? image; + + const AdBannerContent({ + required this.appName, + this.ctaLabel = 'install', + this.image, + }); +} + +/// 알약 모양의 인앱 광고 배너 (시안: onboarding-8.png 참고). +/// +/// 실제 AdMob SDK 연동은 별도 작업으로 분리. 현 단계에서는 동일한 형태의 +/// 정적 UI만 제공하므로, 추후 SDK 응답을 [AdBannerContent]에 매핑해 같은 +/// 위젯을 재사용할 수 있다. +class AppAdBanner extends StatelessWidget { + final AdBannerContent content; + final VoidCallback? onTap; + final VoidCallback? onDismiss; + + const AppAdBanner({ + super.key, + required this.content, + this.onTap, + this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 56.h, + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.h12, + vertical: 6.h, + ), + decoration: BoxDecoration( + color: AppColors.backgroundNormal, + borderRadius: BorderRadius.circular(AppDimensions.radiusPill), + border: Border.all(color: AppColors.borderUnselected), + ), + child: Row( + children: [ + _buildImage(), + SizedBox(width: AppSpacing.h12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + content.appName, + style: AppTypography.body2Bold.copyWith( + color: AppColors.status100, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 4.h), + Row( + children: [ + _chip('Ad'), + SizedBox(width: AppSpacing.h4), + _chip(content.ctaLabel), + ], + ), + ], + ), + ), + SizedBox(width: AppSpacing.h8), + _circleIconButton( + icon: Icons.play_arrow_rounded, + onTap: onTap, + semanticLabel: '광고 보기', + ), + SizedBox(width: AppSpacing.h4), + _circleIconButton( + icon: Icons.close_rounded, + onTap: onDismiss, + semanticLabel: '광고 닫기', + iconColor: AppColors.status100_50, + ), + ], + ), + ); + } + + Widget _buildImage() { + final image = content.image; + return Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + color: AppColors.borderUnselected, + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: Clip.antiAlias, + child: image == null + ? Center( + child: Text( + 'image', + style: AppTypography.caption3Regular.copyWith( + color: AppColors.status100_50, + ), + ), + ) + : Image(image: image, fit: BoxFit.cover), + ); + } + + Widget _chip(String label) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: 8.w, + vertical: 2.h, + ), + decoration: BoxDecoration( + color: AppColors.green100, + borderRadius: BorderRadius.circular(AppDimensions.radiusPill), + ), + child: Text( + label, + style: AppTypography.caption3Regular.copyWith( + color: AppColors.green500, + height: 1.2, + ), + ), + ); + } + + Widget _circleIconButton({ + required IconData icon, + required VoidCallback? onTap, + required String semanticLabel, + Color iconColor = AppColors.green500, + }) { + return Semantics( + button: true, + label: semanticLabel, + child: InkResponse( + onTap: onTap, + radius: 20.r, + child: Icon(icon, size: 22, color: iconColor), + ), + ); + } +} diff --git a/toondo/packages/presentation/lib/views/goal/manage/goal_manage_body.dart b/toondo/packages/presentation/lib/views/goal/manage/goal_manage_body.dart index ae450bc5..81130950 100644 --- a/toondo/packages/presentation/lib/views/goal/manage/goal_manage_body.dart +++ b/toondo/packages/presentation/lib/views/goal/manage/goal_manage_body.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:presentation/designsystem/colors/app_colors.dart'; +import 'package:presentation/designsystem/components/ads/app_ad_banner.dart'; import 'package:presentation/designsystem/components/menu/app_selectable_menu_bar.dart'; import 'package:presentation/designsystem/components/toggles/app_goal_category_toggle.dart'; import 'package:presentation/designsystem/spacing/app_spacing.dart'; @@ -8,9 +9,18 @@ import 'package:presentation/viewmodels/goal/goal_management_viewmodel.dart'; import 'package:presentation/views/goal/widget/goal_manage_list_section.dart'; import 'package:provider/provider.dart'; -class GoalManageBody extends StatelessWidget { +class GoalManageBody extends StatefulWidget { const GoalManageBody({super.key}); + @override + State createState() => _GoalManageBodyState(); +} + +class _GoalManageBodyState extends State { + bool _adDismissed = false; + + static const _placeholderAd = AdBannerContent(appName: 'App Name'); + @override Widget build(BuildContext context) { final viewModel = context.watch(); @@ -20,6 +30,13 @@ class GoalManageBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox(height: AppSpacing.v20), + if (!_adDismissed) + AppAdBanner( + content: _placeholderAd, + onTap: () {}, + onDismiss: () => setState(() => _adDismissed = true), + ), + if (!_adDismissed) SizedBox(height: AppSpacing.v20), _HeaderSection(viewModel: viewModel), SizedBox(height: AppSpacing.v24), viewModel.filteredGoals.isEmpty diff --git a/toondo/packages/presentation/lib/views/welcome/welcome_body.dart b/toondo/packages/presentation/lib/views/welcome/welcome_body.dart index 071d2222..5215817a 100644 --- a/toondo/packages/presentation/lib/views/welcome/welcome_body.dart +++ b/toondo/packages/presentation/lib/views/welcome/welcome_body.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:provider/provider.dart'; @@ -6,9 +7,9 @@ import 'package:presentation/viewmodels/welcome/welcome_viewmodel.dart'; import 'package:presentation/designsystem/colors/app_colors.dart'; import 'package:presentation/designsystem/spacing/app_spacing.dart'; import 'package:presentation/designsystem/typography/app_typography.dart'; -import 'package:presentation/designsystem/components/buttons/app_google_login_button.dart'; -import 'package:presentation/designsystem/components/buttons/app_kakao_login_button.dart'; import 'package:presentation/designsystem/components/buttons/app_phone_login_button.dart'; +import 'package:presentation/views/mypage/help_guide/legal/legal_texts.dart'; +import 'package:presentation/views/welcome/widget/legal_bottom_sheet.dart'; class WelcomeBody extends StatelessWidget { const WelcomeBody({super.key}); @@ -28,7 +29,7 @@ class WelcomeBody extends StatelessWidget { // SizedBox(height: AppSpacing.v52), // 소셜 로그인 사용 시 주석 해제 _buildButtons(context), Spacer(), - _buildTermsText(), + _buildTermsText(context), ], ), ); @@ -105,17 +106,49 @@ class WelcomeBody extends StatelessWidget { ); } - Widget _buildTermsText() { + Widget _buildTermsText(BuildContext context) { + final base = AppTypography.caption3Regular.copyWith( + color: AppColors.green600, + ); + final linkStyle = base.copyWith( + decoration: TextDecoration.underline, + decorationColor: AppColors.green600, + fontWeight: FontWeight.w600, + ); return SafeArea( top: false, child: Padding( padding: const EdgeInsets.only(bottom: 12), - child: Text( - '버튼을 눌러 다음 화면으로 이동 시,\n서비스 이용 약관 및 개인정보 처리 방안에 동의한 것으로 간주합니다.', - textAlign: TextAlign.center, - style: AppTypography.caption3Regular.copyWith( - color: AppColors.green600 - ), + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: base, + children: [ + const TextSpan(text: '버튼을 눌러 다음 화면으로 이동 시,\n'), + TextSpan( + text: '서비스 이용 약관', + style: linkStyle, + recognizer: TapGestureRecognizer() + ..onTap = () => LegalBottomSheet.show( + context, + title: '서비스 이용 약관', + content: LegalTexts.termsOfService, + ), + ), + const TextSpan(text: ' 및 '), + TextSpan( + text: '개인정보 수집 및 이용', + style: linkStyle, + recognizer: TapGestureRecognizer() + ..onTap = () => LegalBottomSheet.show( + context, + title: '개인정보 수집 및 이용', + content: LegalTexts.privacyPolicy, + ), + ), + const TextSpan(text: '에 동의한 것으로 간주합니다.'), + ], + ), ), ), ); diff --git a/toondo/packages/presentation/lib/views/welcome/widget/legal_bottom_sheet.dart b/toondo/packages/presentation/lib/views/welcome/widget/legal_bottom_sheet.dart new file mode 100644 index 00000000..53d5aa9a --- /dev/null +++ b/toondo/packages/presentation/lib/views/welcome/widget/legal_bottom_sheet.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:presentation/designsystem/colors/app_colors.dart'; +import 'package:presentation/designsystem/components/bottom_sheets/app_bottom_sheet.dart'; +import 'package:presentation/designsystem/spacing/app_spacing.dart'; +import 'package:presentation/designsystem/typography/app_typography.dart'; + +class LegalBottomSheet extends StatelessWidget { + final String title; + final String content; + + const LegalBottomSheet({ + super.key, + required this.title, + required this.content, + }); + + static Future show( + BuildContext context, { + required String title, + required String content, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => LegalBottomSheet(title: title, content: content), + ); + } + + @override + Widget build(BuildContext context) { + return AppBottomSheet( + initialSize: 0.8, + maxSize: 0.95, + body: Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.h20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTypography.h2Bold), + SizedBox(height: AppSpacing.v20), + Text( + content, + style: AppTypography.caption1Regular.copyWith( + color: AppColors.status100_75, + height: 1.55, + ), + ), + SizedBox(height: AppSpacing.v32), + ], + ), + ), + ); + } +}