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:
Claude 2026-07-31 01:30:50 +00:00
commit 6743bf0054
No known key found for this signature in database
24 changed files with 431 additions and 49 deletions

View File

@ -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=

94
core-backend/demo.go Normal file
View File

@ -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
}

54
core-backend/demo_test.go Normal file
View File

@ -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)
}
}

View File

@ -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)

View File

@ -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과 이어서 진행 권장)

View File

@ -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 빌드는 **디버그 서명으로 폴백**하며 경고를 낸다

View File

@ -32,7 +32,7 @@
## 2. PoC #3 역할극 스크립트 (프로토타입 보완용)
`bunsin-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
`ykavu-prototype` 클릭 프로토타입이 읽씹 종결·본인확인·거부권·에스컬레이션 4장면을 이미
다루므로, 여기서는 프로토타입에 없는 케이스만 스크립트로 보완한다.
공식 링크 앵커: [`prototype.md`](./prototype.md) (공유 URL은 그 파일의 `SHARE_URL`).

View File

@ -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`과 이 앵커 문서는 유지한다.

View File

@ -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

View File

@ -1,4 +1,4 @@
package com.bunsin.bunsin_mobile
package com.ykavu.ykavu_mobile
import io.flutter.embedding.android.FlutterActivity

View File

@ -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

View File

@ -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';

View File

@ -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.

View File

@ -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 {
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(

View File

@ -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),

View File

@ -106,7 +106,7 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
const SizedBox(height: 12),
FilledButton(
onPressed: () => _save(markDone: true),
child: const Text('저장하고 시작'),
child: const Text('이 말투로 시작'),
),
],
),

View File

@ -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,19 +97,26 @@ 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(
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<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,
),
);
}
}

View File

@ -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:

View File

@ -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:

View File

@ -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() {

View File

@ -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() {

View File

@ -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() {

View File

@ -51,7 +51,7 @@ def load_dotenv_if_present():
break
SYSTEM_PROMPT = """너는 어떤 사람의 '분신'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
SYSTEM_PROMPT = """너는 어떤 사람의 '와카뷰'다. 아래 예시 발화들의 말투(어휘, 문장 길이, 이모티콘 습관, 격식 정도)를 \
그대로 따라서, 대화의 마지막 메시지에 대한 답장 '초안 하나만' 자연스러운 한국어로 작성해라.
지켜야 :

8
scripts/push-both.sh Executable file
View File

@ -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)"