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:provider/provider.dart';
@ -9,9 +12,37 @@ import 'theme/app_theme.dart';
Future<void> 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<SessionState>(
builder: (context, s, _) {
if (s.user == null) return const SignupScreen();

View File

@ -103,7 +103,13 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
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<ConversationListScreen> {
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)

View File

@ -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'

View File

@ -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<OnboardingToneScreen> {
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<SessionState>().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<SessionState>().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('저장하고 시작'),
),
],
),
),
),
);

View File

@ -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<SignupScreen> {
final session = context.watch<SessionState>();
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,
),
);
}
}

View File

@ -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),
);
}
}

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"
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:

View File

@ -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:

View File

@ -29,10 +29,13 @@
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>bunsin_mobile</title>
<title>분신</title>
<link rel="manifest" href="manifest.json">
</head>
<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>
</body>
</html>