diff --git a/.env.example b/.env.example index 19052ef..08a0e4e 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-YKAVU 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..7ce0a22 --- /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-YKAVU" + +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-YKAVU 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 073a558..9d3de30 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"}) @@ -59,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) { @@ -138,31 +163,52 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi // 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용 // 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전 // 방식은 "초대 기반"이 아니었음. + // + // Shared demo: DEMO-YKAVU (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 { @@ -650,6 +696,7 @@ func main() { return } db := openDB() + seedDemoInvite(db) relay := newConnectionManager() ai := newAIServiceClient() r := setupRouter(db, relay, ai) diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 978f933..f368ba0 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -143,7 +143,7 @@ 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` "말투 검색기" 참고) - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 - 4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`bunsin-prototype`). + 4개 장면. 공유 링크 docs 앵커: [`prototype.md`](./prototype.md) (`ykavu-prototype`). PoC #3 역할극 자극재로 사용 - [x] "AI 대리 응답 수용성"(Q3) 인터뷰 질문지 작성 — `user-interview-guide.md` (질문지만 완료. 5~10명 실제 인터뷰는 아직 미착수 — PoC#3과 이어서 진행 권장) diff --git a/docs/android-release.md b/docs/android-release.md index 26307fa..66c7911 100644 --- a/docs/android-release.md +++ b/docs/android-release.md @@ -16,7 +16,7 @@ v1은 **Android만** 대상 (`tech-design.md` §8). Play 스토어 공개가 아 2. Master 머신에서 한 번 생성: ```bash -keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias bunsin +keytool -genkey -v -keystore ~/ykavu-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias ykavu ``` 3. `mobile/android/key.properties.example`을 복사해 `mobile/android/key.properties` 작성: @@ -24,8 +24,8 @@ keytool -genkey -v -keystore ~/bunsin-release.jks -keyalg RSA -keysize 2048 -val ``` storePassword=... keyPassword=... -keyAlias=bunsin -storeFile=/absolute/path/to/bunsin-release.jks +keyAlias=ykavu +storeFile=/absolute/path/to/ykavu-release.jks ``` 4. `key.properties`가 없으면 release 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다 diff --git a/docs/poc-materials.md b/docs/poc-materials.md index 2e50c27..88f78ef 100644 --- a/docs/poc-materials.md +++ b/docs/poc-materials.md @@ -32,7 +32,7 @@ ## 2. PoC #3 역할극 스크립트 (프로토타입 보완용) -`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미 +`ykavu-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미 다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다. 공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`). diff --git a/docs/prototype.md b/docs/prototype.md index 1609159..17a4c94 100644 --- a/docs/prototype.md +++ b/docs/prototype.md @@ -7,7 +7,7 @@ Phase 1 C: PoC #3 역할극에서 쓰는 **클릭 프로토타입**의 공식 | 항목 | 값 | |------|-----| -| **식별자** | `bunsin-prototype` (`poc-materials.md` §2와 동일) | +| **식별자** | `ykavu-prototype` (`poc-materials.md` §2와 동일) | | **공유 URL** | **TBD — Master 기입** (Claude Artifacts 공개 permalink 또는 정적 호스팅 URL) | | **docs 앵커** | 이 파일. 다른 문서는 프로토타입을 말할 때 여기로 링크한다 | @@ -33,4 +33,4 @@ SHARE_URL= 1. 프로토타입 장면을 바꾸면 이 파일에 변경일·요약 한 줄을 추가한다. 2. `SHARE_URL`이 비어 있거나 404면 PoC #3 / 외부 공유 전에 반드시 채운다. -3. 호스팅을 옮겨도 식별자 `bunsin-prototype`과 이 앵커 문서는 유지한다. +3. 호스팅을 옮겨도 식별자 `ykavu-prototype`과 이 앵커 문서는 유지한다. diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 5617d6a..48a5c6f 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -16,7 +16,7 @@ if (hasReleaseKeystore) { } android { - namespace = "com.bunsin.bunsin_mobile" + namespace = "com.ykavu.ykavu_mobile" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -30,7 +30,7 @@ android { } defaultConfig { - applicationId = "com.bunsin.bunsin_mobile" + applicationId = "com.ykavu.ykavu_mobile" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode diff --git a/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/ykavu/ykavu_mobile/MainActivity.kt similarity index 73% rename from mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt rename to mobile/android/app/src/main/kotlin/com/ykavu/ykavu_mobile/MainActivity.kt index 2470c58..a60fbc5 100644 --- a/mobile/android/app/src/main/kotlin/com/bunsin/bunsin_mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/ykavu/ykavu_mobile/MainActivity.kt @@ -1,4 +1,4 @@ -package com.bunsin.bunsin_mobile +package com.ykavu.ykavu_mobile import io.flutter.embedding.android.FlutterActivity diff --git a/mobile/android/key.properties.example b/mobile/android/key.properties.example index 7565a5b..02331a8 100644 --- a/mobile/android/key.properties.example +++ b/mobile/android/key.properties.example @@ -1,4 +1,4 @@ storePassword=replace-me keyPassword=replace-me -keyAlias=bunsin -storeFile=/absolute/path/to/bunsin-release.jks +keyAlias=ykavu +storeFile=/absolute/path/to/ykavu-release.jks diff --git a/mobile/lib/config.dart b/mobile/lib/config.dart index 45c2aa5..509da75 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-YKAVU'; + static const demoDisplayName = '테스터'; + + static String wsBase() { final uri = Uri.parse(coreApiBase); final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; diff --git a/mobile/lib/db/app_database_native.dart b/mobile/lib/db/app_database_native.dart index f490694..65c0ae0 100644 --- a/mobile/lib/db/app_database_native.dart +++ b/mobile/lib/db/app_database_native.dart @@ -83,7 +83,7 @@ const _kDbPassphrase = 'db_passphrase_v1'; QueryExecutor _openEncryptedExecutor() { return LazyDatabase(() async { final dir = await getApplicationDocumentsDirectory(); - final file = File(p.join(dir.path, 'bunsin_encrypted.db')); + final file = File(p.join(dir.path, 'ykavu_encrypted.db')); final passphrase = await _loadOrCreatePassphrase(); // Background isolate does not inherit open.overrideFor — re-apply there. diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index f5f8d46..c57a988 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -11,13 +12,25 @@ import 'widgets/gradient_backdrop.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + + FlutterError.onError = (details) { + FlutterError.presentError(details); + debugPrint('FlutterError: ${details.exceptionAsString()}'); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('Uncaught: $error\n$stack'); + return true; + }; + runApp(const _Bootstrap()); } /// Shows [SplashScreen] while [SessionState.restore] is in flight, then /// swaps to the real app. Runs `runApp` immediately (rather than awaiting /// restore() first) so the branded splash actually paints instead of -/// leaving a blank frame during the async gap. +/// leaving a blank frame during the async gap. Falls back to a readable +/// error screen if restore fails or hangs, rather than crashing silently. class _Bootstrap extends StatefulWidget { const _Bootstrap(); @@ -27,6 +40,8 @@ class _Bootstrap extends StatefulWidget { class _BootstrapState extends State<_Bootstrap> { SessionState? _session; + Object? _error; + StackTrace? _stackTrace; @override void initState() { @@ -35,14 +50,38 @@ class _BootstrapState extends State<_Bootstrap> { } Future _init() async { - final session = SessionState(); - await session.restore(); - if (!mounted) return; - setState(() => _session = session); + try { + final session = SessionState(); + await session.restore().timeout(const Duration(seconds: 8)); + if (!mounted) return; + setState(() => _session = session); + } catch (e, st) { + debugPrint('BOOT FAIL: $e\n$st'); + if (!mounted) return; + setState(() { + _error = e; + _stackTrace = st; + }); + } } @override Widget build(BuildContext context) { + final error = _error; + if (error != null) { + return MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: SelectableText('앱 시작 실패\n\n$error\n\n${_stackTrace ?? ''}'), + ), + ), + ), + ); + } + final session = _session; if (session == null) { return MaterialApp( diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index 2e237cc..3baeaaa 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -31,7 +31,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), diff --git a/mobile/lib/screens/onboarding_tone_screen.dart b/mobile/lib/screens/onboarding_tone_screen.dart index 64436dd..d89ca7e 100644 --- a/mobile/lib/screens/onboarding_tone_screen.dart +++ b/mobile/lib/screens/onboarding_tone_screen.dart @@ -106,7 +106,7 @@ class _OnboardingToneScreenState extends State { const SizedBox(height: 12), FilledButton( onPressed: () => _save(markDone: true), - child: const Text('저장하고 시작'), + child: const Text('이 말투로 시작'), ), ], ), diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index 440e84d..eb50a6b 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -1,7 +1,10 @@ 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'; class SignupScreen extends StatefulWidget { const SignupScreen({super.key}); @@ -21,6 +24,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(); @@ -57,9 +66,20 @@ class _SignupScreenState extends State { textAlign: TextAlign.center, style: theme.textTheme.bodyLarge?.copyWith(color: scheme.onSurfaceVariant), ), - const Spacer(flex: 3), + const Spacer(flex: 2), Text('초대 코드로 클로즈드 베타에 참여합니다', style: theme.textTheme.labelLarge), const SizedBox(height: 12), + _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), TextField( controller: _invite, decoration: const InputDecoration( @@ -67,6 +87,7 @@ class _SignupScreenState extends State { prefixIcon: Icon(Icons.vpn_key), ), textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.characters, ), const SizedBox(height: 12), TextField( @@ -76,19 +97,26 @@ class _SignupScreenState extends State { 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( - 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('시작하기'), + _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 Text('시작하기'), + ), ), const SizedBox(height: 32), ], @@ -98,3 +126,97 @@ class _SignupScreenState extends State { ); } } + +/// Visible test credentials so other people can try the closed beta without +/// asking 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); + final brightness = theme.brightness; + return Material( + color: AppTheme.glassFill(brightness), + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onFill, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.glassBorder(brightness)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '테스트용 (누구나)', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Text( + '초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}', + style: theme.textTheme.titleSmall?.copyWith( + 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: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + ); + } +} + +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/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..a47508f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,4 +1,4 @@ -name: bunsin_mobile +name: ykavu_mobile description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -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/test/local_db_test.dart b/mobile/test/local_db_test.dart index 056afe8..9de3352 100644 --- a/mobile/test/local_db_test.dart +++ b/mobile/test/local_db_test.dart @@ -1,4 +1,4 @@ -import 'package:bunsin_mobile/db/app_database.dart'; +import 'package:ykavu_mobile/db/app_database.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/mobile/test/models_test.dart b/mobile/test/models_test.dart index c5b8dda..4ab0470 100644 --- a/mobile/test/models_test.dart +++ b/mobile/test/models_test.dart @@ -1,4 +1,4 @@ -import 'package:bunsin_mobile/models/models.dart'; +import 'package:ykavu_mobile/models/models.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index 8659e2d..ffbb053 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -1,5 +1,5 @@ -import 'package:bunsin_mobile/main.dart'; -import 'package:bunsin_mobile/state/session_state.dart'; +import 'package:ykavu_mobile/main.dart'; +import 'package:ykavu_mobile/state/session_state.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/poc/tone-corpus/generate_draft.py b/poc/tone-corpus/generate_draft.py index 767154e..139c0fa 100644 --- a/poc/tone-corpus/generate_draft.py +++ b/poc/tone-corpus/generate_draft.py @@ -51,7 +51,7 @@ def load_dotenv_if_present(): break -SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \ +SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \ 그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라. 지켜야 할 것: 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)"