From 941d600cfdfe8fd37f7a7959e11d3506140ab5a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 08:02:28 +0000 Subject: [PATCH 1/4] feat(mobile): Twin Shadow UI for signup and tone onboarding Apply the approved Twin Shadow direction: forest/mist/paper tokens, Manrope typography, full-bleed hero atmosphere with dual silhouettes, brand-first signup copy, and quieter conversation-list alignment. Co-authored-by: okuma --- mobile/lib/main.dart | 41 +++- .../lib/screens/conversation_list_screen.dart | 17 +- mobile/lib/screens/data_flow_screen.dart | 7 +- .../lib/screens/onboarding_tone_screen.dart | 150 ++++++++------ mobile/lib/screens/signup_screen.dart | 184 ++++++++++++------ mobile/lib/theme/app_theme.dart | 162 +++++++++------ mobile/lib/widgets/twin_hero_backdrop.dart | 164 ++++++++++++++++ mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 1 + mobile/web/index.html | 5 +- 10 files changed, 551 insertions(+), 188 deletions(-) create mode 100644 mobile/lib/widgets/twin_hero_backdrop.dart diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 81a65cf..7ef63b1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -9,9 +12,37 @@ import 'theme/app_theme.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - final session = SessionState(); - await session.restore(); - runApp(BunsinApp(session: session)); + + FlutterError.onError = (details) { + FlutterError.presentError(details); + debugPrint('FlutterError: ${details.exceptionAsString()}'); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('Uncaught: $error\n$stack'); + return true; + }; + + try { + final session = SessionState(); + await session.restore().timeout(const Duration(seconds: 8)); + runApp(BunsinApp(session: session)); + } catch (e, st) { + debugPrint('BOOT FAIL: $e\n$st'); + runApp( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: SelectableText('앱 시작 실패\n\n$e\n\n$st'), + ), + ), + ), + ), + ); + } } class BunsinApp extends StatelessWidget { @@ -27,8 +58,8 @@ class BunsinApp extends StatelessWidget { title: '분신', debugShowCheckedModeBanner: false, theme: AppTheme.light(), - darkTheme: AppTheme.dark(), - themeMode: ThemeMode.system, + // Twin Shadow phase-1: light only (dark kept in AppTheme for later). + themeMode: ThemeMode.light, home: Consumer( builder: (context, s, _) { if (s.user == null) return const SignupScreen(); diff --git a/mobile/lib/screens/conversation_list_screen.dart b/mobile/lib/screens/conversation_list_screen.dart index 7430603..dc07a09 100644 --- a/mobile/lib/screens/conversation_list_screen.dart +++ b/mobile/lib/screens/conversation_list_screen.dart @@ -103,7 +103,13 @@ class _ConversationListScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('분신'), + title: Text( + '분신', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + ), + ), actions: [ IconButton( tooltip: '사후 알림', @@ -161,10 +167,13 @@ class _ConversationListScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 4), children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: Text( - session.user == null ? '' : '안녕하세요, ${session.user!.displayName}님', - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + session.user == null ? '' : '${session.user!.displayName}의 대화', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), ), ), if (_error != null) diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index a07c1ed..fc800d9 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../state/session_state.dart'; +import '../theme/app_theme.dart'; /// Shows what stays on-device vs what may leave the device (roadmap B / privacy). class DataFlowScreen extends StatelessWidget { @@ -29,7 +30,7 @@ class DataFlowScreen extends StatelessWidget { width: 36, height: 36, alignment: Alignment.center, - decoration: BoxDecoration(color: tint.withOpacity(0.15), shape: BoxShape.circle), + decoration: BoxDecoration(color: tint.withValues(alpha: 0.15), shape: BoxShape.circle), child: Icon(icon, size: 18, color: tint), ), const SizedBox(width: 12), @@ -65,7 +66,7 @@ class DataFlowScreen extends StatelessWidget { _section( context, icon: Icons.lock_outline, - tint: Colors.green.shade600, + tint: TwinTokens.forest, title: '기기 안에만 둡니다', body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. ' '원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.', @@ -89,7 +90,7 @@ class DataFlowScreen extends StatelessWidget { _section( context, icon: Icons.cloud_upload_outlined, - tint: Colors.amber.shade800, + tint: TwinTokens.twinMark, title: '서버로 보낼 수 있는 것', body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' '채팅 릴레이: 보낸 메시지 본문\n' diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart index 3d7776e..8249a89 100644 --- a/mobile/lib/screens/onboarding_tone_screen.dart +++ b/mobile/lib/screens/onboarding_tone_screen.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../state/session_state.dart'; +import '../theme/app_theme.dart'; +import '../widgets/twin_hero_backdrop.dart'; -/// Phase 1 onboarding skeleton: capture a few style samples locally. -/// Fine copy / import UX waits for human PoC (#1) — do not invent §3 defaults here. +/// Phase 1 onboarding: capture a few style samples locally. +/// Fine copy / import UX waits for human PoC — do not invent §3 defaults here. class OnboardingToneScreen extends StatefulWidget { const OnboardingToneScreen({super.key}); @@ -47,68 +49,104 @@ class _OnboardingToneScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; + final fromMenu = Navigator.of(context).canPop(); + return Scaffold( - appBar: AppBar( - title: const Text('말투 샘플'), - actions: [ - TextButton( - onPressed: () => context.read().skipToneOnboarding(), - child: const Text('나중에'), - ), - const SizedBox(width: 8), - ], - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(24), - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration(color: scheme.primaryContainer, borderRadius: BorderRadius.circular(16)), - child: Icon(Icons.record_voice_over_outlined, size: 28, color: scheme.onPrimaryContainer), - ), - const SizedBox(height: 16), - Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall), - const SizedBox(height: 8), - Text( - '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. ' - '최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.', - style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), - ), - const SizedBox(height: 28), - for (var i = 0; i < _samples.length; i++) ...[ - TextField( - controller: _samples[i], - maxLines: 2, - decoration: InputDecoration( - labelText: '샘플 ${i + 1}', - hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 4, right: 4, top: 4), - child: CircleAvatar( - radius: 12, - backgroundColor: scheme.secondaryContainer, + body: TwinHeroBackdrop( + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), + child: Row( + children: [ + if (fromMenu) + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back), + ) + else + const SizedBox(width: 48), + const Spacer(), + TextButton( + onPressed: () => context.read().skipToneOnboarding(), + child: const Text('나중에'), + ), + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(28, 8, 28, 24), + children: [ + TwinFadeUp( child: Text( - '${i + 1}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: scheme.onSecondaryContainer, + '말투', + style: theme.textTheme.labelLarge?.copyWith( + color: TwinTokens.forest, + letterSpacing: 1.2, ), ), ), - ), + const SizedBox(height: 8), + TwinFadeUp( + delay: const Duration(milliseconds: 80), + child: Text( + '분신이 따라 쓸 말투', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + color: TwinTokens.ink, + ), + ), + ), + const SizedBox(height: 10), + TwinFadeUp( + delay: const Duration(milliseconds: 140), + child: Text( + '자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다.', + style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + const SizedBox(height: 28), + for (var i = 0; i < _samples.length; i++) ...[ + TwinFadeUp( + delay: Duration(milliseconds: 180 + i * 50), + child: TextField( + controller: _samples[i], + maxLines: 2, + decoration: InputDecoration( + labelText: '샘플 ${i + 1}', + hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null, + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 12, right: 4), + child: Align( + widthFactor: 1, + child: Text( + '${i + 1}', + style: theme.textTheme.titleSmall?.copyWith( + color: TwinTokens.forest, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(28, 0, 28, 20), + child: FilledButton( + onPressed: () => _save(markDone: true), + child: const Text('이 말투로 시작'), ), ), - const SizedBox(height: 12), ], - const SizedBox(height: 12), - FilledButton( - onPressed: () => _save(markDone: true), - child: const Text('저장하고 시작'), - ), - ], + ), ), ), ); diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index 173f747..37ed2d3 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../state/session_state.dart'; +import '../theme/app_theme.dart'; +import '../widgets/twin_hero_backdrop.dart'; class SignupScreen extends StatefulWidget { const SignupScreen({super.key}); @@ -26,75 +28,135 @@ class _SignupScreenState extends State { final session = context.watch(); final theme = Theme.of(context); final scheme = theme.colorScheme; + return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Spacer(flex: 3), - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: scheme.primaryContainer, - borderRadius: BorderRadius.circular(22), + body: TwinHeroBackdrop( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(flex: 2), + TwinFadeUp( + child: Text( + '분신', + textAlign: TextAlign.center, + style: theme.textTheme.displayLarge?.copyWith( + fontSize: 56, + fontWeight: FontWeight.w800, + color: TwinTokens.ink, + letterSpacing: -1.6, + ), ), - child: Icon(Icons.auto_awesome, size: 34, color: scheme.onPrimaryContainer), ), - ), - const SizedBox(height: 20), - Text( - '분신', - textAlign: TextAlign.center, - style: theme.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 8), - Text( - '나를 대신해 답하는, 나만의 분신', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant), - ), - const Spacer(flex: 3), - Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge), - const SizedBox(height: 12), - TextField( - controller: _invite, - decoration: const InputDecoration( - labelText: '초대 코드', - prefixIcon: Icon(Icons.vpn_key), - ), - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 12), - TextField( - controller: _name, - decoration: const InputDecoration( - labelText: '표시 이름', - prefixIcon: Icon(Icons.person_outline), - ), - textInputAction: TextInputAction.done, - ), - if (session.error != null) ...[ const SizedBox(height: 12), - Text(session.error!, style: TextStyle(color: scheme.error)), + TwinFadeUp( + delay: const Duration(milliseconds: 90), + child: Text( + '나를 대신해 답하는, 나만의 그림자', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + color: TwinTokens.forestDeep, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + ), + const SizedBox(height: 10), + TwinFadeUp( + delay: const Duration(milliseconds: 160), + child: Text( + '초대 코드로 클로즈드 베타에 참여합니다', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + const Spacer(flex: 3), + TwinFadeUp( + delay: const Duration(milliseconds: 220), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _invite, + decoration: const InputDecoration( + labelText: '초대 코드', + prefixIcon: Icon(Icons.vpn_key_outlined), + ), + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.characters, + ), + const SizedBox(height: 12), + TextField( + controller: _name, + decoration: const InputDecoration( + labelText: '표시 이름', + prefixIcon: Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) { + if (!session.loading) { + session.signup(_invite.text.trim(), _name.text.trim()); + } + }, + ), + if (session.error != null) ...[ + const SizedBox(height: 12), + Text(session.error!, style: TextStyle(color: scheme.error)), + ], + const SizedBox(height: 20), + _PressScale( + child: FilledButton( + onPressed: session.loading + ? null + : () => session.signup(_invite.text.trim(), _name.text.trim()), + child: session.loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('시작하기'), + ), + ), + ], + ), + ), + const SizedBox(height: 28), ], - const SizedBox(height: 20), - FilledButton( - onPressed: session.loading - ? null - : () => session.signup(_invite.text.trim(), _name.text.trim()), - child: session.loading - ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('시작하기'), - ), - const SizedBox(height: 32), - ], + ), ), ), ), ); } } + +class _PressScale extends StatefulWidget { + const _PressScale({required this.child}); + + final Widget child; + + @override + State<_PressScale> createState() => _PressScaleState(); +} + +class _PressScaleState extends State<_PressScale> { + var _down = false; + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: (_) => setState(() => _down = true), + onPointerUp: (_) => setState(() => _down = false), + onPointerCancel: (_) => setState(() => _down = false), + child: AnimatedScale( + scale: _down ? 0.98 : 1, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: widget.child, + ), + ); + } +} diff --git a/mobile/lib/theme/app_theme.dart b/mobile/lib/theme/app_theme.dart index d185243..18508b8 100644 --- a/mobile/lib/theme/app_theme.dart +++ b/mobile/lib/theme/app_theme.dart @@ -1,67 +1,99 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Twin Shadow design tokens for 분신. +/// +/// Brand-first: forest ink on mist/paper atmosphere. `twinAccent` stays a +/// separate warm mark so AI-authored bubbles never read as human-typed +/// (PRD §3.1 분신 뱃지). +class TwinTokens { + TwinTokens._(); + + static const ink = Color(0xFF0E1A16); + static const forest = Color(0xFF1F6F5B); + static const forestDeep = Color(0xFF163F34); + static const mist = Color(0xFFE7F0EC); + static const paper = Color(0xFFF5F7F6); + static const glow = Color(0xFFC8E6D9); + static const twinMark = Color(0xFFB8860B); // muted gold — AI authorship only +} -/// Central design tokens for 분신 (Bunsin). One seed color drives the whole -/// Material 3 palette; `twinAccent` is a separate warm accent used only for -/// the AI-authored badge/border so a twin-written bubble never reads as -/// something the human actually typed (PRD §3.1 분신 뱃지). class AppTheme { AppTheme._(); - static const _seed = Color(0xFF1F6F5B); - static Color twinAccent(Brightness brightness) => - brightness == Brightness.dark ? Colors.amber.shade300 : Colors.amber.shade800; + brightness == Brightness.dark ? const Color(0xFFE0C36A) : TwinTokens.twinMark; + /// Phase-1 default: light Twin Shadow only. static ThemeData light() => _build(Brightness.light); + + /// Kept for system/dark opt-in later; not used while ThemeMode.light. static ThemeData dark() => _build(Brightness.dark); static ThemeData _build(Brightness brightness) { - final scheme = ColorScheme.fromSeed(seedColor: _seed, brightness: brightness); + final isLight = brightness == Brightness.light; + final baseScheme = ColorScheme.fromSeed( + seedColor: TwinTokens.forest, + brightness: brightness, + ); + final scheme = baseScheme.copyWith( + primary: isLight ? TwinTokens.forest : TwinTokens.glow, + onPrimary: isLight ? Colors.white : TwinTokens.ink, + primaryContainer: isLight ? TwinTokens.mist : TwinTokens.forestDeep, + onPrimaryContainer: isLight ? TwinTokens.forestDeep : TwinTokens.mist, + surface: isLight ? TwinTokens.paper : const Color(0xFF0B1210), + onSurface: isLight ? TwinTokens.ink : TwinTokens.mist, + onSurfaceVariant: isLight ? TwinTokens.ink.withValues(alpha: 0.62) : TwinTokens.mist.withValues(alpha: 0.72), + surfaceContainerHighest: isLight ? TwinTokens.mist : const Color(0xFF15201C), + surfaceContainerHigh: isLight ? const Color(0xFFEEF4F1) : const Color(0xFF121A17), + outlineVariant: isLight ? TwinTokens.glow : TwinTokens.forestDeep, + ); + + final textTheme = _textTheme(scheme); return ThemeData( useMaterial3: true, brightness: brightness, colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, - visualDensity: VisualDensity.comfortable, - textTheme: _textTheme(), + visualDensity: VisualDensity.standard, + textTheme: textTheme, + primaryTextTheme: textTheme, appBarTheme: AppBarTheme( backgroundColor: scheme.surface, foregroundColor: scheme.onSurface, - surfaceTintColor: scheme.surfaceTint, + surfaceTintColor: Colors.transparent, elevation: 0, - scrolledUnderElevation: 1, + scrolledUnderElevation: 0.5, centerTitle: false, - titleTextStyle: TextStyle( - fontSize: 20, + titleTextStyle: textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, - color: scheme.onSurface, - letterSpacing: -0.2, + letterSpacing: -0.4, ), ), cardTheme: CardThemeData( elevation: 0, color: scheme.surfaceContainerHigh, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), margin: EdgeInsets.zero, ), listTileTheme: ListTileThemeData( iconColor: scheme.onSurfaceVariant, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ), - dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 32, thickness: 1), + dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 24, thickness: 1), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: scheme.surfaceContainerHighest, + fillColor: isLight ? Colors.white.withValues(alpha: 0.72) : scheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, + borderSide: BorderSide(color: scheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, + borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.8)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), @@ -75,23 +107,30 @@ class AppTheme { borderRadius: BorderRadius.circular(14), borderSide: BorderSide(color: scheme.error, width: 1.6), ), - hintStyle: TextStyle(color: scheme.onSurfaceVariant.withOpacity(0.7)), + labelStyle: TextStyle(color: scheme.onSurfaceVariant), + hintStyle: TextStyle(color: scheme.onSurfaceVariant.withValues(alpha: 0.7)), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( + backgroundColor: TwinTokens.forest, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - textStyle: const TextStyle(fontWeight: FontWeight.w700), + textStyle: GoogleFonts.manrope(fontWeight: FontWeight.w700, fontSize: 16), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + side: BorderSide(color: scheme.outlineVariant), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( + foregroundColor: TwinTokens.forest, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), ), @@ -100,12 +139,20 @@ class AppTheme { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), - segmentedButtonTheme: SegmentedButtonThemeData( - style: SegmentedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: TwinTokens.forest, + foregroundColor: Colors.white, + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary), chipTheme: ChipThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), side: BorderSide.none, @@ -113,37 +160,36 @@ class AppTheme { labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), ), - floatingActionButtonTheme: FloatingActionButtonThemeData( - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, - elevation: 1, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), - ), - snackBarTheme: SnackBarThemeData( - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - bannerTheme: MaterialBannerThemeData( - backgroundColor: scheme.surfaceContainerHigh, - padding: const EdgeInsets.all(16), - ), - dialogTheme: DialogThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ), - progressIndicatorTheme: ProgressIndicatorThemeData(color: scheme.primary), ); } - static TextTheme _textTheme() { - return const TextTheme( - displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5), - headlineSmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.3), - titleLarge: TextStyle(fontWeight: FontWeight.w700), - titleMedium: TextStyle(fontWeight: FontWeight.w700), - titleSmall: TextStyle(fontWeight: FontWeight.w700), - bodyLarge: TextStyle(height: 1.4), - bodyMedium: TextStyle(height: 1.4), - labelLarge: TextStyle(fontWeight: FontWeight.w700), + static TextTheme _textTheme(ColorScheme scheme) { + final base = GoogleFonts.manropeTextTheme(); + return base.copyWith( + displayLarge: base.displayLarge?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -1.2, + color: scheme.onSurface, + height: 1.05, + ), + displaySmall: base.displaySmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.8, + color: scheme.onSurface, + height: 1.1, + ), + headlineSmall: base.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + color: scheme.onSurface, + ), + titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), + titleMedium: base.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), + titleSmall: base.titleSmall?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), + bodyLarge: base.bodyLarge?.copyWith(height: 1.45, color: scheme.onSurface), + bodyMedium: base.bodyMedium?.copyWith(height: 1.45, color: scheme.onSurface), + bodySmall: base.bodySmall?.copyWith(height: 1.4, color: scheme.onSurfaceVariant), + labelLarge: base.labelLarge?.copyWith(fontWeight: FontWeight.w700, color: scheme.onSurface), ); } } diff --git a/mobile/lib/widgets/twin_hero_backdrop.dart b/mobile/lib/widgets/twin_hero_backdrop.dart new file mode 100644 index 0000000..b6b8080 --- /dev/null +++ b/mobile/lib/widgets/twin_hero_backdrop.dart @@ -0,0 +1,164 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Full-bleed Twin Shadow atmosphere: mist→paper gradient + overlapping +/// silhouettes (the "second self"). No badges, chips, or promo overlays. +class TwinHeroBackdrop extends StatefulWidget { + const TwinHeroBackdrop({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _TwinHeroBackdropState(); +} + +class _TwinHeroBackdropState extends State with SingleTickerProviderStateMixin { + late final AnimationController _drift; + + @override + void initState() { + super.initState(); + _drift = AnimationController(vsync: this, duration: const Duration(seconds: 14))..repeat(reverse: true); + } + + @override + void dispose() { + _drift.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _drift, + builder: (context, _) { + final t = Curves.easeInOut.transform(_drift.value); + return Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment(-0.2 + t * 0.15, -1), + end: Alignment(0.3 - t * 0.1, 1.1), + colors: const [ + TwinTokens.glow, + TwinTokens.mist, + TwinTokens.paper, + ], + stops: const [0.0, 0.42, 1.0], + ), + ), + ), + CustomPaint( + painter: _TwinSilhouettePainter(phase: t), + ), + widget.child, + ], + ); + }, + ); + } +} + +class _TwinSilhouettePainter extends CustomPainter { + _TwinSilhouettePainter({required this.phase}); + + final double phase; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final paintA = Paint()..color = TwinTokens.forest.withValues(alpha: 0.07 + phase * 0.02); + final paintB = Paint()..color = TwinTokens.ink.withValues(alpha: 0.05 + (1 - phase) * 0.02); + + // Soft overlapping ovals — a person and their shadow-self. + final a = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(w * 0.72 + phase * 8, h * 0.22), + width: w * 0.55, + height: h * 0.42, + ), + const Radius.circular(120), + ); + final b = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(w * 0.78 + phase * 4, h * 0.26), + width: w * 0.48, + height: h * 0.38, + ), + const Radius.circular(120), + ); + canvas.drawRRect(a, paintA); + canvas.drawRRect(b, paintB); + + // Thin crescent arc suggesting a second outline. + final arcPaint = Paint() + ..color = TwinTokens.forest.withValues(alpha: 0.12) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4; + canvas.drawArc( + Rect.fromCenter(center: Offset(w * 0.78, h * 0.24), width: w * 0.42, height: h * 0.34), + -math.pi * 0.2, + math.pi * 1.1, + false, + arcPaint, + ); + } + + @override + bool shouldRepaint(covariant _TwinSilhouettePainter oldDelegate) => oldDelegate.phase != phase; +} + +/// Brand wordmark entrance — fade + slight rise. +class TwinFadeUp extends StatefulWidget { + const TwinFadeUp({ + super.key, + required this.child, + this.delay = Duration.zero, + this.duration = const Duration(milliseconds: 700), + }); + + final Widget child; + final Duration delay; + final Duration duration; + + @override + State createState() => _TwinFadeUpState(); +} + +class _TwinFadeUpState extends State with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final Animation _opacity; + late final Animation _offset; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: widget.duration); + _opacity = CurvedAnimation(parent: _c, curve: Curves.easeOutCubic); + _offset = Tween(begin: const Offset(0, 0.06), end: Offset.zero) + .animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)); + Future.delayed(widget.delay, () { + if (mounted) _c.forward(); + }); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: SlideTransition(position: _offset, child: widget.child), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 49b1427..dbf7405 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -296,6 +296,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + url: "https://pub.dev" + source: hosted + version: "6.3.2" graphs: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ec43028..b93f30c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: sqlite3: ^2.9.4 flutter_secure_storage: ^10.3.1 sqlcipher_flutter_libs: ^0.6.8 + google_fonts: ^6.3.2 dev_dependencies: flutter_test: diff --git a/mobile/web/index.html b/mobile/web/index.html index 1e7c1e7..360b98f 100644 --- a/mobile/web/index.html +++ b/mobile/web/index.html @@ -29,10 +29,13 @@ - bunsin_mobile + 분신 +
+ 분신 로딩 중… +
From 9abc09c2a69a254a649cb98449c3b22d373451bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 08:16:28 +0000 Subject: [PATCH 2/4] fix(core-backend): enable CORS for Flutter Web signup Chrome treats localhost:5555 and 127.0.0.1:8080 as different origins. Handle OPTIONS preflight and emit Access-Control-Allow-* so /auth/signup works from flutter run -d chrome. Co-authored-by: okuma --- core-backend/main.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/core-backend/main.go b/core-backend/main.go index 58d08d6..fc86e7c 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -49,8 +49,32 @@ type draftMessageRequest struct { K int `json:"k"` } +// corsMiddleware allows Flutter Web (and other local origins) to call the API. +// Browsers treat http://localhost:5555 and http://127.0.0.1:8080 as different +// origins, so Chrome signup fails without OPTIONS + Allow-Origin headers. +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin == "" { + origin = "*" + } + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Max-Age", "600") + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} + func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gin.Engine { r := gin.Default() + r.Use(corsMiddleware()) r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) From 17b10a0e8c6c623d2ef29b40acbdd3102190a710 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 08:21:22 +0000 Subject: [PATCH 3/4] feat: show shared DEMO-BUNSIN invite on signup for testers Seed a reusable demo invite (ALLOW_DEMO_INVITE, default on) and surface it on the Flutter signup screen with one-tap fill so others can try without asking for a one-off admin mint. Co-authored-by: okuma --- .env.example | 3 + core-backend/demo.go | 94 +++++++++++++++++++++++++++ core-backend/demo_test.go | 54 +++++++++++++++ core-backend/main.go | 53 ++++++++++----- mobile/lib/config.dart | 6 ++ mobile/lib/screens/signup_screen.dart | 88 ++++++++++++++++++++++++- 6 files changed, 281 insertions(+), 17 deletions(-) create mode 100644 core-backend/demo.go create mode 100644 core-backend/demo_test.go diff --git a/.env.example b/.env.example index 19052ef..22b613a 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ GEMINI_API_KEY= # core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test) ADMIN_API_TOKEN= +# Shared demo invite DEMO-BUNSIN on signup UI (multiple testers). Set 0 in production. +ALLOW_DEMO_INVITE=1 + # Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test). # Without this, notifyUser soft-skips and records push_skipped metrics. FCM_SERVER_KEY= diff --git a/core-backend/demo.go b/core-backend/demo.go new file mode 100644 index 0000000..0232fa8 --- /dev/null +++ b/core-backend/demo.go @@ -0,0 +1,94 @@ +package main + +import ( + "log" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// Shared closed-beta demo invite shown on the Flutter signup screen. +// Multiple testers can use the same code when ALLOW_DEMO_INVITE is enabled. +const demoInviteCode = "DEMO-BUNSIN" + +func demoInviteEnabled() bool { + v := strings.TrimSpace(os.Getenv("ALLOW_DEMO_INVITE")) + if v == "" { + // Default on for local / Phase-1 shared testing. Set ALLOW_DEMO_INVITE=0 in prod. + return true + } + switch strings.ToLower(v) { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func isDemoInviteCode(code string) bool { + return strings.EqualFold(strings.TrimSpace(code), demoInviteCode) +} + +func seedDemoInvite(db *gorm.DB) { + if !demoInviteEnabled() { + return + } + var existing InviteCode + err := db.Where("code = ?", demoInviteCode).First(&existing).Error + if err == nil { + return + } + if err != gorm.ErrRecordNotFound { + log.Printf("demo invite lookup: %v", err) + return + } + inv := InviteCode{ + Code: demoInviteCode, + Note: "shared demo invite (ALLOW_DEMO_INVITE)", + } + if err := db.Create(&inv).Error; err != nil { + log.Printf("demo invite seed failed: %v", err) + return + } + log.Printf("seeded demo invite code %s", demoInviteCode) +} + +func registerDemoRoutes(r *gin.Engine) { + r.GET("/demo", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "demo_invite_enabled": demoInviteEnabled(), + "demo_invite_code": demoInviteCode, + "demo_display_name": "테스터", + "hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.", + }) + }) +} + +// demoSignupInviteCode returns the InviteCode row for demo signups, creating it +// if needed. Usability checks for "already used" are skipped by the caller. +func demoSignupInviteCode(db *gorm.DB) (InviteCode, error) { + var invite InviteCode + err := db.Where("code = ?", demoInviteCode).First(&invite).Error + if err == nil { + return invite, nil + } + if err != gorm.ErrRecordNotFound { + return InviteCode{}, err + } + seedDemoInvite(db) + err = db.Where("code = ?", demoInviteCode).First(&invite).Error + return invite, err +} + +// uniqueDemoUserInvite stores a per-user value on users.invite_code (unique) +// while still accepting the shared DEMO-BUNSIN input. +func uniqueDemoUserInvite() (string, error) { + suffix, err := generateInviteCode() + if err != nil { + return "", err + } + return demoInviteCode + "-" + suffix, nil +} diff --git a/core-backend/demo_test.go b/core-backend/demo_test.go new file mode 100644 index 0000000..b0db64c --- /dev/null +++ b/core-backend/demo_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "testing" +) + +func TestDemoInviteReusableForMultipleSignups(t *testing.T) { + t.Setenv("ALLOW_DEMO_INVITE", "1") + server, db := setupTestServer(t) + seedDemoInvite(db) + + demoResp, err := http.Get(server.URL + "/demo") + if err != nil { + t.Fatalf("GET /demo: %v", err) + } + defer demoResp.Body.Close() + var demo map[string]any + if err := json.NewDecoder(demoResp.Body).Decode(&demo); err != nil { + t.Fatalf("decode demo: %v", err) + } + if demo["demo_invite_code"] != demoInviteCode { + t.Fatalf("demo code: %v", demo["demo_invite_code"]) + } + + a := postJSON(t, server.URL+"/auth/signup", signupRequest{ + InviteCode: demoInviteCode, + DisplayName: "테스터A", + }) + defer a.Body.Close() + aBody, _ := io.ReadAll(a.Body) + if a.StatusCode != http.StatusOK { + t.Fatalf("first demo signup: %d %s", a.StatusCode, aBody) + } + + b := postJSON(t, server.URL+"/auth/signup", signupRequest{ + InviteCode: demoInviteCode, + DisplayName: "테스터B", + }) + defer b.Body.Close() + bBody, _ := io.ReadAll(b.Body) + if b.StatusCode != http.StatusOK { + t.Fatalf("second demo signup should reuse code: %d %s", b.StatusCode, bBody) + } + + var outA, outB map[string]any + _ = json.Unmarshal(aBody, &outA) + _ = json.Unmarshal(bBody, &outB) + if outA["id"] == outB["id"] { + t.Fatalf("expected distinct users, got %#v %#v", outA, outB) + } +} diff --git a/core-backend/main.go b/core-backend/main.go index fc86e7c..d39d85b 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -83,6 +83,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi registerA1A2Routes(r, db) registerBRoutes(r, db) registerInviteOpsRoutes(r, db) + registerDemoRoutes(r) r.GET("/admin/metrics", func(c *gin.Context) { if !requireAdmin(c) { @@ -162,31 +163,52 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용 // 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전 // 방식은 "초대 기반"이 아니었음. + // + // Shared demo: DEMO-BUNSIN (ALLOW_DEMO_INVITE, default on) can be reused + // by multiple testers; User.InviteCode still gets a unique stored value. + demo := demoInviteEnabled() && isDemoInviteCode(req.InviteCode) var invite InviteCode - if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) - return - } - if ok, detail := inviteUsable(invite, time.Now()); !ok { - status := http.StatusBadRequest - if detail == "invite code already used" { - status = http.StatusConflict + storedInvite := req.InviteCode + if demo { + var err error + invite, err = demoSignupInviteCode(db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + storedInvite, err = uniqueDemoUserInvite() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + } else { + if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) + return + } + if ok, detail := inviteUsable(invite, time.Now()); !ok { + status := http.StatusBadRequest + if detail == "invite code already used" { + status = http.StatusConflict + } + c.JSON(status, gin.H{"detail": detail}) + return } - c.JSON(status, gin.H{"detail": detail}) - return } - user := User{InviteCode: req.InviteCode, DisplayName: req.DisplayName} + user := User{InviteCode: storedInvite, DisplayName: req.DisplayName} if err := db.Create(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0}) - now := time.Now() - invite.UsedAt = &now - invite.UsedByUserID = &user.ID - db.Save(&invite) + if !demo { + now := time.Now() + invite.UsedAt = &now + invite.UsedByUserID = &user.ID + db.Save(&invite) + } session, err := createSession(db, user.ID) if err != nil { @@ -674,6 +696,7 @@ func main() { return } db := openDB() + seedDemoInvite(db) relay := newConnectionManager() ai := newAIServiceClient() r := setupRouter(db, relay, ai) diff --git a/mobile/lib/config.dart b/mobile/lib/config.dart index 45c2aa5..7496b84 100644 --- a/mobile/lib/config.dart +++ b/mobile/lib/config.dart @@ -8,6 +8,12 @@ class AppConfig { defaultValue: 'http://10.0.2.2:8080', // Android emulator → host localhost ); + /// Shared demo invite (must match core-backend `demoInviteCode`). + /// Shown on the signup screen so other testers can join without admin mint. + static const demoInviteCode = 'DEMO-BUNSIN'; + static const demoDisplayName = '테스터'; + + static String wsBase() { final uri = Uri.parse(coreApiBase); final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index 37ed2d3..1a5745b 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../config.dart'; import '../state/session_state.dart'; import '../theme/app_theme.dart'; import '../widgets/twin_hero_backdrop.dart'; @@ -23,6 +25,12 @@ class _SignupScreenState extends State { super.dispose(); } + void _fillDemoCredentials() { + _invite.text = AppConfig.demoInviteCode; + _name.text = AppConfig.demoDisplayName; + setState(() {}); + } + @override Widget build(BuildContext context) { final session = context.watch(); @@ -72,9 +80,23 @@ class _SignupScreenState extends State { style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), ), ), - const Spacer(flex: 3), + const Spacer(flex: 2), TwinFadeUp( - delay: const Duration(milliseconds: 220), + delay: const Duration(milliseconds: 200), + child: _DemoTestPanel( + onFill: _fillDemoCredentials, + onCopy: () async { + await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')), + ); + }, + ), + ), + const SizedBox(height: 16), + TwinFadeUp( + delay: const Duration(milliseconds: 240), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -133,6 +155,68 @@ class _SignupScreenState extends State { } } +/// Visible test credentials so other people can try the closed beta without +/// asking Master for a one-off invite mint. +class _DemoTestPanel extends StatelessWidget { + const _DemoTestPanel({required this.onFill, required this.onCopy}); + + final VoidCallback onFill; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: TwinTokens.mist.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onFill, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '테스트용 (누구나)', + style: theme.textTheme.labelLarge?.copyWith( + color: TwinTokens.forest, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Text( + '초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}', + style: theme.textTheme.titleSmall?.copyWith( + color: TwinTokens.ink, + height: 1.45, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: '코드 복사', + onPressed: onCopy, + icon: const Icon(Icons.copy_rounded, size: 20), + ), + ], + ), + const SizedBox(height: 4), + Text( + '탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능', + style: theme.textTheme.bodySmall?.copyWith(color: TwinTokens.ink.withValues(alpha: 0.55)), + ), + ], + ), + ), + ), + ); + } +} + class _PressScale extends StatefulWidget { const _PressScale({required this.child}); From c4ecb5862b4c08bf113ee67191b78b834a869a9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 08:47:40 +0000 Subject: [PATCH 4/4] chore: add push-both script for GitHub + Gitea remotes Keeps o0kuma/hikikomori and gitea oh/iykyka in sync from this workspace. Co-authored-by: okuma --- scripts/push-both.sh | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100755 scripts/push-both.sh diff --git a/scripts/push-both.sh b/scripts/push-both.sh new file mode 100755 index 0000000..ef28999 --- /dev/null +++ b/scripts/push-both.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Push main to GitHub (origin) and Gitea (gitea) remotes. +set -euo pipefail +BRANCH="${1:-main}" +cd "$(dirname "$0")/.." +git push -u origin "$BRANCH" +git push -u gitea "$BRANCH" +echo "Pushed $BRANCH → origin (GitHub) + gitea (gitea.iykyka.com/oh/iykyka)"