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: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();
|
||||||
final session = SessionState();
|
|
||||||
await session.restore();
|
FlutterError.onError = (details) {
|
||||||
runApp(BunsinApp(session: session));
|
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 {
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,68 +49,104 @@ 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(
|
||||||
TextButton(
|
children: [
|
||||||
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
|
Padding(
|
||||||
child: const Text('나중에'),
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
],
|
if (fromMenu)
|
||||||
),
|
IconButton(
|
||||||
body: SafeArea(
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
child: ListView(
|
icon: const Icon(Icons.arrow_back),
|
||||||
padding: const EdgeInsets.all(24),
|
)
|
||||||
children: [
|
else
|
||||||
Container(
|
const SizedBox(width: 48),
|
||||||
width: 56,
|
const Spacer(),
|
||||||
height: 56,
|
TextButton(
|
||||||
decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)),
|
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
|
||||||
child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer),
|
child: const Text('나중에'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
],
|
||||||
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Text(
|
Expanded(
|
||||||
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
|
child: ListView(
|
||||||
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
|
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
children: [
|
||||||
),
|
TwinFadeUp(
|
||||||
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,
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${i + 1}',
|
'말투',
|
||||||
style: TextStyle(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
fontSize: 12,
|
color: TwinTokens.forest,
|
||||||
fontWeight: FontWeight.w700,
|
letterSpacing: 1.2,
|
||||||
color: scheme.onSecondaryContainer,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
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('저장하고 시작'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,75 +28,135 @@ 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: Padding(
|
child: SafeArea(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Spacer(flex: 3),
|
children: [
|
||||||
Center(
|
const Spacer(flex: 2),
|
||||||
child: Container(
|
TwinFadeUp(
|
||||||
width: 72,
|
child: Text(
|
||||||
height: 72,
|
'분신',
|
||||||
decoration: BoxDecoration(
|
textAlign: TextAlign.center,
|
||||||
color: scheme.primaryContainer,
|
style: theme.textTheme.displayLarge?.copyWith(
|
||||||
borderRadius: BorderRadius.circular(22),
|
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),
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
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(
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue