From 95964e4879152c01172cc3fdf6979776fbf2b698 Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 18:21:01 +0900 Subject: [PATCH 1/3] feat(presentation): make welcome screen terms tappable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom sentence on the welcome screen now uses RichText with two tappable spans, "서비스 이용 약관" and "개인정보 수집 및 이용", that each open a scrollable BottomSheet showing the full LegalTexts. The non-link copy is corrected from "개인정보 처리 방안" to "개인정보 수집 및 이용" to match the team request. A small LegalBottomSheet widget wraps AppBottomSheet so the same component can be reused later (e.g., from onboarding or settings). Co-Authored-By: Claude Opus 4.7 --- .../lib/views/welcome/welcome_body.dart | 53 ++++++++++++++---- .../welcome/widget/legal_bottom_sheet.dart | 55 +++++++++++++++++++ 2 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 toondo/packages/presentation/lib/views/welcome/widget/legal_bottom_sheet.dart 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), + ], + ), + ), + ); + } +} From cbab44726d65d4f0a8cbffd42fa193fcf68a1a1f Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 18:23:09 +0900 Subject: [PATCH 2/3] feat(presentation): add AppAdBanner design system component Pill-shaped banner matching the Figma onboarding-8 spec: 40x40 image slot on the left, app name + "Ad"/install chips in the middle, play and dismiss circular icon buttons on the right. The widget is purely presentational and takes content through an AdBannerContent value object so AdMob SDK responses can later be mapped onto the same shape without touching the view. Co-Authored-By: Claude Opus 4.7 --- .../components/ads/app_ad_banner.dart | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 toondo/packages/presentation/lib/designsystem/components/ads/app_ad_banner.dart 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), + ), + ); + } +} From fb7a1bbe74aa4e2ba0c46805345a60b63c3c92c2 Mon Sep 17 00:00:00 2001 From: Jun Seo Date: Sat, 6 Jun 2026 18:24:05 +0900 Subject: [PATCH 3/3] feat(presentation): mount ad banner on goal manage screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Places the new AppAdBanner above the "목표 관리하기" header, matching the Figma onboarding-8 layout. The body becomes a StatefulWidget so the close (×) button can hide the banner for the rest of the session. Static placeholder content is used until AdMob SDK integration ships in a follow-up PR. Co-Authored-By: Claude Opus 4.7 --- .../views/goal/manage/goal_manage_body.dart | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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