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:
parent
6eceef44f8
commit
941d600cfd
|
|
@ -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();
|
||||
|
||||
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();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,54 +49,84 @@ 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: [
|
||||
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('나중에'),
|
||||
),
|
||||
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),
|
||||
Expanded(
|
||||
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),
|
||||
Text(
|
||||
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
|
||||
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
|
||||
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++) ...[
|
||||
TextField(
|
||||
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: 4, right: 4, top: 4),
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: scheme.secondaryContainer,
|
||||
padding: const EdgeInsets.only(left: 12, right: 4),
|
||||
child: Align(
|
||||
widthFactor: 1,
|
||||
child: Text(
|
||||
'${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSecondaryContainer,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: TwinTokens.forest,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -103,14 +135,20 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 28, 20),
|
||||
child: FilledButton(
|
||||
onPressed: () => _save(markDone: true),
|
||||
child: const Text('저장하고 시작'),
|
||||
child: const Text('이 말투로 시작'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,47 +28,64 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
final session = context.watch<SessionState>();
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
body: TwinHeroBackdrop(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
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),
|
||||
),
|
||||
child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
const Spacer(flex: 2),
|
||||
TwinFadeUp(
|
||||
child: Text(
|
||||
'분신',
|
||||
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,
|
||||
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),
|
||||
Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 12),
|
||||
TwinFadeUp(
|
||||
delay: const Duration(milliseconds: 220),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _invite,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '초대 코드',
|
||||
prefixIcon: Icon(Icons.vpn_key),
|
||||
prefixIcon: Icon(Icons.vpn_key_outlined),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
|
|
@ -76,24 +95,67 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
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),
|
||||
FilledButton(
|
||||
_PressScale(
|
||||
child: 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 SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue