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 <o0kuma@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-30 08:02:28 +00:00
parent 6eceef44f8
commit 941d600cfd
No known key found for this signature in database
10 changed files with 551 additions and 188 deletions

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -9,9 +12,37 @@ import 'theme/app_theme.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
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(); final session = SessionState();
await session.restore(); await session.restore().timeout(const Duration(seconds: 8));
runApp(BunsinApp(session: session)); 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 { class BunsinApp extends StatelessWidget {
@ -27,8 +58,8 @@ class BunsinApp extends StatelessWidget {
title: '분신', title: '분신',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), // Twin Shadow phase-1: light only (dark kept in AppTheme for later).
themeMode: ThemeMode.system, themeMode: ThemeMode.light,
home: Consumer<SessionState>( home: Consumer<SessionState>(
builder: (context, s, _) { builder: (context, s, _) {
if (s.user == null) return const SignupScreen(); if (s.user == null) return const SignupScreen();

View File

@ -103,7 +103,13 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('분신'), title: Text(
'분신',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
),
),
actions: [ actions: [
IconButton( IconButton(
tooltip: '사후 알림', tooltip: '사후 알림',
@ -161,10 +167,13 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Text( child: Text(
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}', session.user == null ? '' : '${session.user!.displayName}의 대화',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
), ),
), ),
if (_error != null) if (_error != null)

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../state/session_state.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). /// Shows what stays on-device vs what may leave the device (roadmap B / privacy).
class DataFlowScreen extends StatelessWidget { class DataFlowScreen extends StatelessWidget {
@ -29,7 +30,7 @@ class DataFlowScreen extends StatelessWidget {
width: 36, width: 36,
height: 36, height: 36,
alignment: Alignment.center, 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), child: Icon(icon, size: 18, color: tint),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -65,7 +66,7 @@ class DataFlowScreen extends StatelessWidget {
_section( _section(
context, context,
icon: Icons.lock_outline, icon: Icons.lock_outline,
tint: Colors.green.shade600, tint: TwinTokens.forest,
title: '기기 안에만 둡니다', title: '기기 안에만 둡니다',
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. ' body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.', '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
@ -89,7 +90,7 @@ class DataFlowScreen extends StatelessWidget {
_section( _section(
context, context,
icon: Icons.cloud_upload_outlined, icon: Icons.cloud_upload_outlined,
tint: Colors.amber.shade800, tint: TwinTokens.twinMark,
title: '서버로 보낼 수 있는 것', title: '서버로 보낼 수 있는 것',
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n' '채팅 릴레이: 보낸 메시지 본문\n'

View File

@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../state/session_state.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. /// Phase 1 onboarding: capture a few style samples locally.
/// Fine copy / import UX waits for human PoC (#1) do not invent §3 defaults here. /// Fine copy / import UX waits for human PoC do not invent §3 defaults here.
class OnboardingToneScreen extends StatefulWidget { class OnboardingToneScreen extends StatefulWidget {
const OnboardingToneScreen({super.key}); const OnboardingToneScreen({super.key});
@ -47,54 +49,84 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
final fromMenu = Navigator.of(context).canPop();
return Scaffold( return Scaffold(
appBar: AppBar( body: TwinHeroBackdrop(
title: const Text('말투 샘플'), child: SafeArea(
actions: [ 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( TextButton(
onPressed: () => context.read<SessionState>().skipToneOnboarding(), onPressed: () => context.read<SessionState>().skipToneOnboarding(),
child: const Text('나중에'), 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), Expanded(
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall), child: ListView(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
children: [
TwinFadeUp(
child: Text(
'말투',
style: theme.textTheme.labelLarge?.copyWith(
color: TwinTokens.forest,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( TwinFadeUp(
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. ' delay: const Duration(milliseconds: 80),
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.', 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), style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
), ),
),
const SizedBox(height: 28), const SizedBox(height: 28),
for (var i = 0; i < _samples.length; i++) ...[ for (var i = 0; i < _samples.length; i++) ...[
TextField( TwinFadeUp(
delay: Duration(milliseconds: 180 + i * 50),
child: TextField(
controller: _samples[i], controller: _samples[i],
maxLines: 2, maxLines: 2,
decoration: InputDecoration( decoration: InputDecoration(
labelText: '샘플 ${i + 1}', labelText: '샘플 ${i + 1}',
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
prefixIcon: Padding( prefixIcon: Padding(
padding: const EdgeInsets.only(left: 4, right: 4, top: 4), padding: const EdgeInsets.only(left: 12, right: 4),
child: CircleAvatar( child: Align(
radius: 12, widthFactor: 1,
backgroundColor: scheme.secondaryContainer,
child: Text( child: Text(
'${i + 1}', '${i + 1}',
style: TextStyle( style: theme.textTheme.titleSmall?.copyWith(
fontSize: 12, color: TwinTokens.forest,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
color: scheme.onSecondaryContainer, ),
), ),
), ),
), ),
@ -103,14 +135,20 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
const SizedBox(height: 12), ],
FilledButton( ),
),
Padding(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 20),
child: FilledButton(
onPressed: () => _save(markDone: true), onPressed: () => _save(markDone: true),
child: const Text('저장하고 시작'), child: const Text('이 말투로 시작'),
),
), ),
], ],
), ),
), ),
),
); );
} }
} }

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../theme/app_theme.dart';
import '../widgets/twin_hero_backdrop.dart';
class SignupScreen extends StatefulWidget { class SignupScreen extends StatefulWidget {
const SignupScreen({super.key}); const SignupScreen({super.key});
@ -26,47 +28,64 @@ class _SignupScreenState extends State<SignupScreen> {
final session = context.watch<SessionState>(); final session = context.watch<SessionState>();
final theme = Theme.of(context); final theme = Theme.of(context);
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
return Scaffold( return Scaffold(
body: SafeArea( body: TwinHeroBackdrop(
child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Spacer(flex: 3), const Spacer(flex: 2),
Center( TwinFadeUp(
child: Container( child: Text(
width: 72,
height: 72,
decoration: BoxDecoration(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(22),
),
child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer),
),
),
const SizedBox(height: 20),
Text(
'분신', '분신',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800), style: theme.textTheme.displayLarge?.copyWith(
fontSize: 56,
fontWeight: FontWeight.w800,
color: TwinTokens.ink,
letterSpacing: -1.6,
), ),
const SizedBox(height: 8), ),
Text( ),
'나를 대신해 답하는, 나만의 분신', const SizedBox(height: 12),
TwinFadeUp(
delay: const Duration(milliseconds: 90),
child: Text(
'나를 대신해 답하는, 나만의 그림자',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant), 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), const Spacer(flex: 3),
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge), TwinFadeUp(
const SizedBox(height: 12), delay: const Duration(milliseconds: 220),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField( TextField(
controller: _invite, controller: _invite,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: '초대 코드', labelText: '초대 코드',
prefixIcon: Icon(Icons.vpn_key), prefixIcon: Icon(Icons.vpn_key_outlined),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.characters,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
@ -76,24 +95,67 @@ class _SignupScreenState extends State<SignupScreen> {
prefixIcon: Icon(Icons.person_outline), prefixIcon: Icon(Icons.person_outline),
), ),
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (!session.loading) {
session.signup(_invite.text.trim(), _name.text.trim());
}
},
), ),
if (session.error != null) ...[ if (session.error != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text(session.error!, style: TextStyle(color: scheme.error)), Text(session.error!, style: TextStyle(color: scheme.error)),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton( _PressScale(
child: FilledButton(
onPressed: session.loading onPressed: session.loading
? null ? null
: () => session.signup(_invite.text.trim(), _name.text.trim()), : () => session.signup(_invite.text.trim(), _name.text.trim()),
child: session.loading child: session.loading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('시작하기'), : const Text('시작하기'),
), ),
const SizedBox(height: 32), ),
], ],
), ),
), ),
const SizedBox(height: 28),
],
),
),
),
),
);
}
}
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,
), ),
); );
} }

View File

@ -1,67 +1,99 @@
import 'package:flutter/material.dart'; 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 { class AppTheme {
AppTheme._(); AppTheme._();
static const _seed = Color(0xFF1F6F5B);
static Color twinAccent(Brightness brightness) => 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); 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 dark() => _build(Brightness.dark);
static ThemeData _build(Brightness brightness) { 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( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: brightness, brightness: brightness,
colorScheme: scheme, colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface, scaffoldBackgroundColor: scheme.surface,
visualDensity: VisualDensity.comfortable, visualDensity: VisualDensity.standard,
textTheme: _textTheme(), textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: scheme.surface, backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface, foregroundColor: scheme.onSurface,
surfaceTintColor: scheme.surfaceTint, surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
scrolledUnderElevation: 1, scrolledUnderElevation: 0.5,
centerTitle: false, centerTitle: false,
titleTextStyle: TextStyle( titleTextStyle: textTheme.titleLarge?.copyWith(
fontSize: 20,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: scheme.onSurface, letterSpacing: -0.4,
letterSpacing: -0.2,
), ),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 0, elevation: 0,
color: scheme.surfaceContainerHigh, color: scheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
), ),
listTileTheme: ListTileThemeData( listTileTheme: ListTileThemeData(
iconColor: scheme.onSurfaceVariant, iconColor: scheme.onSurfaceVariant,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 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( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: scheme.surfaceContainerHighest, fillColor: isLight ? Colors.white.withValues(alpha: 0.72) : scheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none, borderSide: BorderSide(color: scheme.outlineVariant),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none, borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.8)),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
@ -75,23 +107,30 @@ class AppTheme {
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.6), 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( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: TwinTokens.forest,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontWeight: FontWeight.w700), textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700, fontSize: 16),
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
side: BorderSide(color: scheme.outlineVariant),
), ),
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: TwinTokens.forest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
), ),
), ),
@ -100,12 +139,20 @@ class AppTheme {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
), ),
segmentedButtonTheme: SegmentedButtonThemeData( floatingActionButtonTheme: FloatingActionButtonThemeData(
style: SegmentedButton.styleFrom( 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)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
), ),
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
), ),
progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary),
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
side: BorderSide.none, side: BorderSide.none,
@ -113,37 +160,36 @@ class AppTheme {
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12), labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), 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() { static TextTheme _textTheme(ColorScheme scheme) {
return const TextTheme( final base = GoogleFonts.manropeTextTheme();
displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5), return base.copyWith(
headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3), displayLarge: base.displayLarge?.copyWith(
titleLarge: TextStyle(fontWeight: FontWeight.w700), fontWeight: FontWeight.w800,
titleMedium: TextStyle(fontWeight: FontWeight.w700), letterSpacing: -1.2,
titleSmall: TextStyle(fontWeight: FontWeight.w700), color: scheme.onSurface,
bodyLarge: TextStyle(height: 1.4), height: 1.05,
bodyMedium: TextStyle(height: 1.4), ),
labelLarge: TextStyle(fontWeight: FontWeight.w700), 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),
); );
} }
} }

View File

@ -0,0 +1,164 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Full-bleed Twin Shadow atmosphere: mistpaper 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<TwinHeroBackdrop> createState() => _TwinHeroBackdropState();
}
class _TwinHeroBackdropState extends State<TwinHeroBackdrop> 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<TwinFadeUp> createState() => _TwinFadeUpState();
}
class _TwinFadeUpState extends State<TwinFadeUp> with SingleTickerProviderStateMixin {
late final AnimationController _c;
late final Animation<double> _opacity;
late final Animation<Offset> _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<void>.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),
);
}
}

View File

@ -296,6 +296,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" 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: graphs:
dependency: transitive dependency: transitive
description: description:

View File

@ -44,6 +44,7 @@ dependencies:
sqlite3: ^2.9.4 sqlite3: ^2.9.4
flutter_secure_storage: ^10.3.1 flutter_secure_storage: ^10.3.1
sqlcipher_flutter_libs: ^0.6.8 sqlcipher_flutter_libs: ^0.6.8
google_fonts: ^6.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -29,10 +29,13 @@
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>bunsin_mobile</title> <title>분신</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>
<div id="bunsin-boot" style="font-family: Manrope, system-ui, sans-serif; padding: 24px; color: #1F6F5B;">
분신 로딩 중…
</div>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>
</body> </body>
</html> </html>