Merge origin/main: keep gradient/glassmorphism UI, port demo-invite + boot hardening, purge 분신 branding
- Resolve visual-direction conflicts (app_theme, main.dart, signup/onboarding screens, index.html) in favor of the soft-gradient + glassmorphism design; drop the competing Twin Shadow palette and TwinTokens usage. - Port non-visual additions from the parallel branch: CORS middleware, FlutterError/PlatformDispatcher crash handlers + boot timeout/fallback screen in main.dart, and the shared demo-invite feature (core-backend/demo.go, demo_test.go, mobile/lib/config.dart), reskinning the demo panel to match the glass UI. - Rename the shared demo invite code DEMO-BUNSIN -> DEMO-YKAVU on both client and server so the tester-facing feature keeps working. - Purge remaining "분신"/"bunsin" identifiers app-wide: Dart package name (bunsin_mobile -> ykavu_mobile), Android applicationId/namespace (com.bunsin.bunsin_mobile -> com.ykavu.ykavu_mobile, incl. Kotlin source dir move), on-device DB filename, keystore alias/docs, PoC draft-generator system prompt, and the static web boot placeholder div.
This commit is contained in:
commit
6743bf0054
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,7 +163,25 @@ 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
|
||||
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
|
||||
|
|
@ -151,18 +194,21 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
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})
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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과 이어서 진행 권장)
|
||||
|
|
|
|||
|
|
@ -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 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
## 2. PoC #3 역할극 스크립트 (프로토타입 보완용)
|
||||
|
||||
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
|
||||
`ykavu-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
|
||||
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
|
||||
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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`과 이 앵커 문서는 유지한다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.bunsin.bunsin_mobile
|
||||
package com.ykavu.ykavu_mobile
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<void> _init() async {
|
||||
try {
|
||||
final session = SessionState();
|
||||
await session.restore();
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
|||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () => _save(markDone: true),
|
||||
child: const Text('저장하고 시작'),
|
||||
child: const Text('이 말투로 시작'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<SignupScreen> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _fillDemoCredentials() {
|
||||
_invite.text = AppConfig.demoInviteCode;
|
||||
_name.text = AppConfig.demoDisplayName;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionState>();
|
||||
|
|
@ -57,9 +66,20 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
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<SignupScreen> {
|
|||
prefixIcon: Icon(Icons.vpn_key),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
|
|
@ -76,13 +97,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) {
|
||||
if (!session.loading) {
|
||||
session.signup(_invite.text.trim(), _name.text.trim());
|
||||
}
|
||||
},
|
||||
),
|
||||
if (session.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(session.error!, style: TextStyle(color: scheme.error)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
_PressScale(
|
||||
child: FilledButton(
|
||||
onPressed: session.loading
|
||||
? null
|
||||
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
||||
|
|
@ -90,6 +117,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('시작하기'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
|
|
@ -98,3 +126,97 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ def load_dotenv_if_present():
|
|||
break
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'이다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
|
||||
SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
|
||||
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
|
||||
|
||||
지켜야 할 것:
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
Loading…
Reference in New Issue