From 941d600cfdfe8fd37f7a7959e11d3506140ab5a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 08:02:28 +0000 Subject: [PATCH] feat(mobile): Twin Shadow UI for signup and tone onboarding Apply the approved Twin Shadow direction: forest/mist/paper tokens, Manrope typography, full-bleed hero atmosphere with dual silhouettes, brand-first signup copy, and quieter conversation-list alignment. Co-authored-by: okuma --- mobile/lib/main.dart | 41 +++- .../lib/screens/conversation_list_screen.dart | 17 +- mobile/lib/screens/data_flow_screen.dart | 7 +- .../lib/screens/onboarding_tone_screen.dart | 150 ++++++++------ mobile/lib/screens/signup_screen.dart | 184 ++++++++++++------ mobile/lib/theme/app_theme.dart | 162 +++++++++------ mobile/lib/widgets/twin_hero_backdrop.dart | 164 ++++++++++++++++ mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 1 + mobile/web/index.html | 5 +- 10 files changed, 551 insertions(+), 188 deletions(-) create mode 100644 mobile/lib/widgets/twin_hero_backdrop.dart diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 81a65cf..7ef63b1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -9,9 +12,37 @@ import 'theme/app_theme.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - final session = SessionState(); - await session.restore(); - runApp(BunsinApp(session: session)); + + FlutterError.onError = (details) { + FlutterError.presentError(details); + debugPrint('FlutterError: ${details.exceptionAsString()}'); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('Uncaught: $error\n$stack'); + return true; + }; + + try { + final session = SessionState(); + await session.restore().timeout(const Duration(seconds: 8)); + runApp(BunsinApp(session: session)); + } catch (e, st) { + debugPrint('BOOT FAIL: $e\n$st'); + runApp( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: SelectableText('앱 시작 실패\n\n$e\n\n$st'), + ), + ), + ), + ), + ); + } } class BunsinApp extends StatelessWidget { @@ -27,8 +58,8 @@ class BunsinApp extends StatelessWidget { title: '분신', debugShowCheckedModeBanner: false, theme: AppTheme.light(), - darkTheme: AppTheme.dark(), - themeMode: ThemeMode.system, + // Twin Shadow phase-1: light only (dark kept in AppTheme for later). + themeMode: ThemeMode.light, home: Consumer( builder: (context, s, _) { if (s.user == null) return const SignupScreen(); diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 7430603..dc07a09 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -103,7 +103,13 @@ class _ConversationListScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('분신'), + title: Text( + '분신', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + ), + ), actions: [ IconButton( tooltip: '사후 알림', @@ -161,10 +167,13 @@ class _ConversationListScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 4), children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: Text( - session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + session.user == null ? '' : '${session.user!.displayName}의 대화', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), ), ), if (_error != null) diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index a07c1ed..fc800d9 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../state/session_state.dart'; +import '../theme/app_theme.dart'; /// Shows what stays on-device vs what may leave the device (roadmap B / privacy). class DataFlowScreen extends StatelessWidget { @@ -29,7 +30,7 @@ class DataFlowScreen extends StatelessWidget { width: 36, height: 36, alignment: Alignment.center, - decoration: BoxDecoration(color: tint.withOpacity(0.15), shape: BoxShape.circle), + decoration: BoxDecoration(color: tint.withValues(alpha: 0.15), shape: BoxShape.circle), child: Icon(icon, size: 18, color: tint), ), const SizedBox(width: 12), @@ -65,7 +66,7 @@ class DataFlowScreen extends StatelessWidget { _section( context, icon: Icons.lock_outline, - tint: Colors.green.shade600, + tint: TwinTokens.forest, title: '기기 안에만 둡니다', body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. ' '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.', @@ -89,7 +90,7 @@ class DataFlowScreen extends StatelessWidget { _section( context, icon: Icons.cloud_upload_outlined, - tint: Colors.amber.shade800, + tint: TwinTokens.twinMark, title: '서버로 보낼 수 있는 것', body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' '채팅 릴레이: 보낸 메시지 본문\n' diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart index 3d7776e..8249a89 100644 --- a/mobile/lib/screens/onboarding_tone_screen.dart +++ b/mobile/lib/screens/onboarding_tone_screen.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../state/session_state.dart'; +import '../theme/app_theme.dart'; +import '../widgets/twin_hero_backdrop.dart'; -/// Phase 1 onboarding skeleton: capture a few style samples locally. -/// Fine copy / import UX waits for human PoC (#1) — do not invent §3 defaults here. +/// Phase 1 onboarding: capture a few style samples locally. +/// Fine copy / import UX waits for human PoC — do not invent §3 defaults here. class OnboardingToneScreen extends StatefulWidget { const OnboardingToneScreen({super.key}); @@ -47,68 +49,104 @@ class _OnboardingToneScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; + final fromMenu = Navigator.of(context).canPop(); + return Scaffold( - appBar: AppBar( - title: const Text('말투 샘플'), - actions: [ - TextButton( - onPressed: () => context.read().skipToneOnboarding(), - child: const Text('나중에'), - ), - const SizedBox(width: 8), - ], - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(24), - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)), - child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer), - ), - const SizedBox(height: 16), - Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall), - const SizedBox(height: 8), - Text( - '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. ' - '최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.', - style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), - ), - const SizedBox(height: 28), - for (var i = 0; i < _samples.length; i++) ...[ - TextField( - controller: _samples[i], - maxLines: 2, - decoration: InputDecoration( - labelText: '샘플 ${i + 1}', - hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 4, right: 4, top: 4), - child: CircleAvatar( - radius: 12, - backgroundColor: scheme.secondaryContainer, + body: TwinHeroBackdrop( + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), + child: Row( + children: [ + if (fromMenu) + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back), + ) + else + const SizedBox(width: 48), + const Spacer(), + TextButton( + onPressed: () => context.read().skipToneOnboarding(), + child: const Text('나중에'), + ), + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(28, 8, 28, 24), + children: [ + TwinFadeUp( child: Text( - '${i + 1}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: scheme.onSecondaryContainer, + '말투', + style: theme.textTheme.labelLarge?.copyWith( + color: TwinTokens.forest, + letterSpacing: 1.2, ), ), ), - ), + const SizedBox(height: 8), + TwinFadeUp( + delay: const Duration(milliseconds: 80), + child: Text( + '분신이 따라 쓸 말투', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + color: TwinTokens.ink, + ), + ), + ), + const SizedBox(height: 10), + TwinFadeUp( + delay: const Duration(milliseconds: 140), + child: Text( + '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + const SizedBox(height: 28), + for (var i = 0; i < _samples.length; i++) ...[ + TwinFadeUp( + delay: Duration(milliseconds: 180 + i * 50), + child: TextField( + controller: _samples[i], + maxLines: 2, + decoration: InputDecoration( + labelText: '샘플 ${i + 1}', + hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 12, right: 4), + child: Align( + widthFactor: 1, + child: Text( + '${i + 1}', + style: theme.textTheme.titleSmall?.copyWith( + color: TwinTokens.forest, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(28, 0, 28, 20), + child: FilledButton( + onPressed: () => _save(markDone: true), + child: const Text('이 말투로 시작'), ), ), - const SizedBox(height: 12), ], - const SizedBox(height: 12), - FilledButton( - onPressed: () => _save(markDone: true), - child: const Text('저장하고 시작'), - ), - ], + ), ), ), ); diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index 173f747..37ed2d3 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../state/session_state.dart'; +import '../theme/app_theme.dart'; +import '../widgets/twin_hero_backdrop.dart'; class SignupScreen extends StatefulWidget { const SignupScreen({super.key}); @@ -26,75 +28,135 @@ class _SignupScreenState extends State { final session = context.watch(); final theme = Theme.of(context); final scheme = theme.colorScheme; + return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Spacer(flex: 3), - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: scheme.primaryContainer, - borderRadius: BorderRadius.circular(22), + body: TwinHeroBackdrop( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(flex: 2), + TwinFadeUp( + child: Text( + '분신', + textAlign: TextAlign.center, + style: theme.textTheme.displayLarge?.copyWith( + fontSize: 56, + fontWeight: FontWeight.w800, + color: TwinTokens.ink, + letterSpacing: -1.6, + ), ), - child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer), ), - ), - const SizedBox(height: 20), - Text( - '분신', - textAlign: TextAlign.center, - style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 8), - Text( - '나를 대신해 답하는, 나만의 분신', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant), - ), - const Spacer(flex: 3), - Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge), - const SizedBox(height: 12), - TextField( - controller: _invite, - decoration: const InputDecoration( - labelText: '초대 코드', - prefixIcon: Icon(Icons.vpn_key), - ), - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 12), - TextField( - controller: _name, - decoration: const InputDecoration( - labelText: '표시 이름', - prefixIcon: Icon(Icons.person_outline), - ), - textInputAction: TextInputAction.done, - ), - if (session.error != null) ...[ const SizedBox(height: 12), - Text(session.error!, style: TextStyle(color: scheme.error)), + TwinFadeUp( + delay: const Duration(milliseconds: 90), + child: Text( + '나를 대신해 답하는, 나만의 그림자', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + color: TwinTokens.forestDeep, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + ), + const SizedBox(height: 10), + TwinFadeUp( + delay: const Duration(milliseconds: 160), + child: Text( + '초대 코드로 클로즈드 베타에 참여합니다', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + const Spacer(flex: 3), + TwinFadeUp( + delay: const Duration(milliseconds: 220), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _invite, + decoration: const InputDecoration( + labelText: '초대 코드', + prefixIcon: Icon(Icons.vpn_key_outlined), + ), + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.characters, + ), + const SizedBox(height: 12), + TextField( + controller: _name, + decoration: const InputDecoration( + labelText: '표시 이름', + prefixIcon: Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (!session.loading) { + session.signup(_invite.text.trim(), _name.text.trim()); + } + }, + ), + if (session.error != null) ...[ + const SizedBox(height: 12), + Text(session.error!, style: TextStyle(color: scheme.error)), + ], + const SizedBox(height: 20), + _PressScale( + child: FilledButton( + onPressed: session.loading + ? null + : () => session.signup(_invite.text.trim(), _name.text.trim()), + child: session.loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('시작하기'), + ), + ), + ], + ), + ), + const SizedBox(height: 28), ], - const SizedBox(height: 20), - FilledButton( - onPressed: session.loading - ? null - : () => session.signup(_invite.text.trim(), _name.text.trim()), - child: session.loading - ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('시작하기'), - ), - const SizedBox(height: 32), - ], + ), ), ), ), ); } } + +class _PressScale extends StatefulWidget { + const _PressScale({required this.child}); + + final Widget child; + + @override + State<_PressScale> createState() => _PressScaleState(); +} + +class _PressScaleState extends State<_PressScale> { + var _down = false; + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: (_) => setState(() => _down = true), + onPointerUp: (_) => setState(() => _down = false), + onPointerCancel: (_) => setState(() => _down = false), + child: AnimatedScale( + scale: _down ? 0.98 : 1, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: widget.child, + ), + ); + } +} diff --git a/mobile/lib/theme/app_theme.dart b/mobile/lib/theme/app_theme.dart index d185243..18508b8 100644 --- a/mobile/lib/theme/app_theme.dart +++ b/mobile/lib/theme/app_theme.dart @@ -1,67 +1,99 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Twin Shadow design tokens for 분신. +/// +/// Brand-first: forest ink on mist/paper atmosphere. `twinAccent` stays a +/// separate warm mark so AI-authored bubbles never read as human-typed +/// (PRD §3.1 분신 뱃지). +class TwinTokens { + TwinTokens._(); + + static const ink = Color(0xFF0E1A16); + static const forest = Color(0xFF1F6F5B); + static const forestDeep = Color(0xFF163F34); + static const mist = Color(0xFFE7F0EC); + static const paper = Color(0xFFF5F7F6); + static const glow = Color(0xFFC8E6D9); + static const twinMark = Color(0xFFB8860B); // muted gold — AI authorship only +} -/// Central design tokens for 분신 (Bunsin). One seed color drives the whole -/// Material 3 palette; `twinAccent` is a separate warm accent used only for -/// the AI-authored badge/border so a twin-written bubble never reads as -/// something the human actually typed (PRD §3.1 분신 뱃지). class AppTheme { AppTheme._(); - static const _seed = Color(0xFF1F6F5B); - static Color twinAccent(Brightness brightness) => - brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800; + brightness == Brightness.dark ? const Color(0xFFE0C36A) : TwinTokens.twinMark; + /// Phase-1 default: light Twin Shadow only. static ThemeData light() => _build(Brightness.light); + + /// Kept for system/dark opt-in later; not used while ThemeMode.light. static ThemeData dark() => _build(Brightness.dark); static ThemeData _build(Brightness brightness) { - final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness); + final isLight = brightness == Brightness.light; + final baseScheme = ColorScheme.fromSeed( + seedColor: TwinTokens.forest, + brightness: brightness, + ); + final scheme = baseScheme.copyWith( + primary: isLight ? TwinTokens.forest : TwinTokens.glow, + onPrimary: isLight ? Colors.white : TwinTokens.ink, + primaryContainer: isLight ? TwinTokens.mist : TwinTokens.forestDeep, + onPrimaryContainer: isLight ? TwinTokens.forestDeep : TwinTokens.mist, + surface: isLight ? TwinTokens.paper : const Color(0xFF0B1210), + onSurface: isLight ? TwinTokens.ink : TwinTokens.mist, + onSurfaceVariant: isLight ? TwinTokens.ink.withValues(alpha: 0.62) : TwinTokens.mist.withValues(alpha: 0.72), + surfaceContainerHighest: isLight ? TwinTokens.mist : const Color(0xFF15201C), + surfaceContainerHigh: isLight ? const Color(0xFFEEF4F1) : const Color(0xFF121A17), + outlineVariant: isLight ? TwinTokens.glow : TwinTokens.forestDeep, + ); + + final textTheme = _textTheme(scheme); return ThemeData( useMaterial3: true, brightness: brightness, colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, - visualDensity: VisualDensity.comfortable, - textTheme: _textTheme(), + visualDensity: VisualDensity.standard, + textTheme: textTheme, + primaryTextTheme: textTheme, appBarTheme: AppBarTheme( backgroundColor: scheme.surface, foregroundColor: scheme.onSurface, - surfaceTintColor: scheme.surfaceTint, + surfaceTintColor: Colors.transparent, elevation: 0, - scrolledUnderElevation: 1, + scrolledUnderElevation: 0.5, centerTitle: false, - titleTextStyle: TextStyle( - fontSize: 20, + titleTextStyle: textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, - color: scheme.onSurface, - letterSpacing: -0.2, + letterSpacing: -0.4, ), ), cardTheme: CardThemeData( elevation: 0, color: scheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), margin: EdgeInsets.zero, ), listTileTheme: ListTileThemeData( iconColor: scheme.onSurfaceVariant, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ), - dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1), + dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 24, thickness: 1), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: scheme.surfaceContainerHighest, + fillColor: isLight ? Colors.white.withValues(alpha: 0.72) : scheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, + borderSide: BorderSide(color: scheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, + borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.8)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), @@ -75,23 +107,30 @@ class AppTheme { borderRadius: BorderRadius.circular(14), borderSide: BorderSide(color: scheme.error, width: 1.6), ), - hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)), + labelStyle: TextStyle(color: scheme.onSurfaceVariant), + hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.7)), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( + backgroundColor: TwinTokens.forest, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - textStyle: const TextStyle(fontWeight: FontWeight.w700), + textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700, fontSize: 16), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + side: BorderSide(color: scheme.outlineVariant), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( + foregroundColor: TwinTokens.forest, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), ), @@ -100,12 +139,20 @@ class AppTheme { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), - segmentedButtonTheme: SegmentedButtonThemeData( - style: SegmentedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: TwinTokens.forest, + foregroundColor: Colors.white, + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary), chipTheme: ChipThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), side: BorderSide.none, @@ -113,37 +160,36 @@ class AppTheme { labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), ), - floatingActionButtonTheme: FloatingActionButtonThemeData( - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, - elevation: 1, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), - ), - snackBarTheme: SnackBarThemeData( - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - bannerTheme: MaterialBannerThemeData( - backgroundColor: scheme.surfaceContainerHigh, - padding: const EdgeInsets.all(16), - ), - dialogTheme: DialogThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary), ); } - static TextTheme _textTheme() { - return const TextTheme( - displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5), - headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3), - titleLarge: TextStyle(fontWeight: FontWeight.w700), - titleMedium: TextStyle(fontWeight: FontWeight.w700), - titleSmall: TextStyle(fontWeight: FontWeight.w700), - bodyLarge: TextStyle(height: 1.4), - bodyMedium: TextStyle(height: 1.4), - labelLarge: TextStyle(fontWeight: FontWeight.w700), + static TextTheme _textTheme(ColorScheme scheme) { + final base = GoogleFonts.manropeTextTheme(); + return base.copyWith( + displayLarge: base.displayLarge?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -1.2, + color: scheme.onSurface, + height: 1.05, + ), + displaySmall: base.displaySmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.8, + color: scheme.onSurface, + height: 1.1, + ), + headlineSmall: base.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + color: scheme.onSurface, + ), + titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), + titleMedium: base.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), + titleSmall: base.titleSmall?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), + bodyLarge: base.bodyLarge?.copyWith(height: 1.45, color: scheme.onSurface), + bodyMedium: base.bodyMedium?.copyWith(height: 1.45, color: scheme.onSurface), + bodySmall: base.bodySmall?.copyWith(height: 1.4, color: scheme.onSurfaceVariant), + labelLarge: base.labelLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), ); } } diff --git a/mobile/lib/widgets/twin_hero_backdrop.dart b/mobile/lib/widgets/twin_hero_backdrop.dart new file mode 100644 index 0000000..b6b8080 --- /dev/null +++ b/mobile/lib/widgets/twin_hero_backdrop.dart @@ -0,0 +1,164 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Full-bleed Twin Shadow atmosphere: mist→paper gradient + overlapping +/// silhouettes (the "second self"). No badges, chips, or promo overlays. +class TwinHeroBackdrop extends StatefulWidget { + const TwinHeroBackdrop({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _TwinHeroBackdropState(); +} + +class _TwinHeroBackdropState extends State with SingleTickerProviderStateMixin { + late final AnimationController _drift; + + @override + void initState() { + super.initState(); + _drift = AnimationController(vsync: this, duration: const Duration(seconds: 14))..repeat(reverse: true); + } + + @override + void dispose() { + _drift.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _drift, + builder: (context, _) { + final t = Curves.easeInOut.transform(_drift.value); + return Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment(-0.2 + t * 0.15, -1), + end: Alignment(0.3 - t * 0.1, 1.1), + colors: const [ + TwinTokens.glow, + TwinTokens.mist, + TwinTokens.paper, + ], + stops: const [0.0, 0.42, 1.0], + ), + ), + ), + CustomPaint( + painter: _TwinSilhouettePainter(phase: t), + ), + widget.child, + ], + ); + }, + ); + } +} + +class _TwinSilhouettePainter extends CustomPainter { + _TwinSilhouettePainter({required this.phase}); + + final double phase; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final paintA = Paint()..color = TwinTokens.forest.withValues(alpha: 0.07 + phase * 0.02); + final paintB = Paint()..color = TwinTokens.ink.withValues(alpha: 0.05 + (1 - phase) * 0.02); + + // Soft overlapping ovals — a person and their shadow-self. + final a = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(w * 0.72 + phase * 8, h * 0.22), + width: w * 0.55, + height: h * 0.42, + ), + const Radius.circular(120), + ); + final b = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(w * 0.78 + phase * 4, h * 0.26), + width: w * 0.48, + height: h * 0.38, + ), + const Radius.circular(120), + ); + canvas.drawRRect(a, paintA); + canvas.drawRRect(b, paintB); + + // Thin crescent arc suggesting a second outline. + final arcPaint = Paint() + ..color = TwinTokens.forest.withValues(alpha: 0.12) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4; + canvas.drawArc( + Rect.fromCenter(center: Offset(w * 0.78, h * 0.24), width: w * 0.42, height: h * 0.34), + -math.pi * 0.2, + math.pi * 1.1, + false, + arcPaint, + ); + } + + @override + bool shouldRepaint(covariant _TwinSilhouettePainter oldDelegate) => oldDelegate.phase != phase; +} + +/// Brand wordmark entrance — fade + slight rise. +class TwinFadeUp extends StatefulWidget { + const TwinFadeUp({ + super.key, + required this.child, + this.delay = Duration.zero, + this.duration = const Duration(milliseconds: 700), + }); + + final Widget child; + final Duration delay; + final Duration duration; + + @override + State createState() => _TwinFadeUpState(); +} + +class _TwinFadeUpState extends State with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final Animation _opacity; + late final Animation _offset; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: widget.duration); + _opacity = CurvedAnimation(parent: _c, curve: Curves.easeOutCubic); + _offset = Tween(begin: const Offset(0, 0.06), end: Offset.zero) + .animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)); + Future.delayed(widget.delay, () { + if (mounted) _c.forward(); + }); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: SlideTransition(position: _offset, child: widget.child), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 49b1427..dbf7405 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -296,6 +296,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + url: "https://pub.dev" + source: hosted + version: "6.3.2" graphs: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ec43028..b93f30c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: sqlite3: ^2.9.4 flutter_secure_storage: ^10.3.1 sqlcipher_flutter_libs: ^0.6.8 + google_fonts: ^6.3.2 dev_dependencies: flutter_test: diff --git a/mobile/web/index.html b/mobile/web/index.html index 1e7c1e7..360b98f 100644 --- a/mobile/web/index.html +++ b/mobile/web/index.html @@ -29,10 +29,13 @@ - bunsin_mobile + 분신 +
+ 분신 로딩 중… +