Compare commits

..

12 Commits

Author SHA1 Message Date
Cursor Agent 17b10a0e8c
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 <o0kuma@users.noreply.github.com>
2026-07-30 08:21:22 +00:00
Cursor Agent 9abc09c2a6
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 <o0kuma@users.noreply.github.com>
2026-07-30 08:16:28 +00:00
Cursor Agent 941d600cfd
feat(mobile): Twin Shadow UI for signup and tone onboarding
Apply the approved Twin Shadow direction: forest/mist/paper tokens,
Manrope typography, full-bleed hero atmosphere with dual silhouettes,
brand-first signup copy, and quieter conversation-list alignment.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-07-30 08:02:28 +00:00
Claude 6eceef44f8
Merge claude/project-planning-approach-ukdz31: Flutter UI polish 2026-07-30 07:18:50 +00:00
Claude 9cd52f884d
Merge cursor/fix-web-db-stub-382d: Flutter Web/Chrome compile stub 2026-07-30 07:18:45 +00:00
Claude 5ce189a76a
Flutter UI 디자인 다듬기 — 테마 시스템 도입 + 화면별 시각 개선
일관된 디자인 토큰(mobile/lib/theme/app_theme.dart)을 새로 만들고
전체 화면에 적용:
- 라이트/다크 ColorScheme을 명시적으로 정의(기존엔 라이트만 있어서
  다크 모드 기기에서 깨져 보였음), AppBar/Card/입력창/버튼/칩/배너 등
  컴포넌트 테마를 통일. "분신" 뱃지 전용 twin 강조색(amber) 분리
- 화면마다 제각각이던 TextField 테두리를 전부 테마 하나로 통일
  (chat_screen의 L1 편집창에 하드코딩돼 있던 Colors.white70 배경도
  제거 — 다크 모드에서 흰 배경에 흰 글씨로 안 보이던 실제 버그였음)

화면별 개선:
- signup: 로고 마크 + 안내 문구 추가, 아이콘 붙인 입력창
- 대화 목록: 앱바 아이콘 4개를 겹쳐놨던 걸 정리(더보기 메뉴로 이동),
  색상 아바타, 빈 상태 안내, 확장형 FAB
- message_bubble: 좌우 비대칭 말풍선 모양(꼬리 느낌), 타임스탬프 추가,
  분신 뱃지를 아이콘+칩 형태로
- chat: 거부권 버튼에 확인 다이얼로그 추가(실수로 눌러도 되돌릴 수
  없는 동작이라 안전장치), L1/에스컬레이션 패널 카드화
- contacts/inbox/sessions/autonomy_settings/data_flow/onboarding:
  아바타, 섹션 카드, 빈 상태, 화이트리스트 칩 UI 등 톤 통일

주의: 이 작업 환경에는 Flutter SDK가 없어 flutter analyze/run/test로
직접 검증하지 못했음. 아이콘 이름 등은 기존 코드에 이미 쓰인 것 위주로
맞췄지만, 로컬에서 flutter analyze 먼저 돌려보는 걸 권장.
2026-07-30 07:14:25 +00:00
Cursor Agent 43aaa31f3c
fix Flutter Web/Chrome compile by stubbing SQLCipher DB
dart:ffi and SQLCipher cannot compile for Chrome. Split AppDatabase into
native (Drift+SQLCipher) and web in-memory stub via conditional export, and
enable the web platform so flutter run -d chrome works for UI preview.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-07-30 07:05:31 +00:00
cursor[bot] 86f65d27ce
Merge pull request #10 from o0kuma/claude/project-planning-approach-ukdz31
Sync main: Phase 1 A–C (Flutter, APIs, beta-ready)
2026-07-30 05:57:46 +00:00
cursor[bot] c46c5263de
Merge pull request #9 from o0kuma/cursor/phase1-c-beta-ready-382d
Phase 1 C: Q1–Q7 confirmed, invite ops, Android release path
2026-07-30 05:57:19 +00:00
Cursor Agent fb4feceab2
Sync AGENTS/CLAUDE: Q1~Q7 confirmed, Phase 1 A–C complete
Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-07-30 05:49:18 +00:00
Cursor Agent 8c1f9f0b0c
Merge planning branch: Phase 1 A1/A2 APIs
Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-07-30 04:47:23 +00:00
cursor[bot] 6986c4e15f
Merge pull request #1 from o0kuma/claude/project-planning-approach-ukdz31
Add planning approach and doc set for AI-twin messenger idea
2026-07-30 04:17:03 +00:00
35 changed files with 1888 additions and 553 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-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=

View File

@ -18,9 +18,10 @@ When documents disagree, follow this order:
Notes:
- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting
decisions. Do not silently reverse them. If a change is required, update
`decision-log.md` and all derived docs in the same change.
- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
sub-questions (default autonomy level, whitelist defaults, final branding)
stay open — do not invent those. To reverse a Q, update `decision-log.md` and
derived docs in the same change.
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
current working answers.
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**

View File

@ -4,10 +4,10 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md).
Quick context:
- This repo started as planning docs only; Phase 1 app-build has now begun.
Check `docs/roadmap.md` Phase 1 checklist before starting app-build work.
- Working product name: **분신** (tentative).
- Source of working decisions: `docs/decision-log.md` (status: 제안).
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is
**D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults.
- Working product name: **분신** (가칭 확정).
- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open).
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
- Never weaken escalation, twin badge, peer veto, or undo.

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

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-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 {
@ -650,6 +696,7 @@ func main() {
return
}
db := openDB()
seedDemoInvite(db)
relay := newConnectionManager()
ai := newAIServiceClient()
r := setupRouter(db, relay, ai)

View File

@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
- platform: android
- platform: web
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323

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-BUNSIN';
static const demoDisplayName = '테스터';
static String wsBase() {
final uri = Uri.parse(coreApiBase);
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';

View File

@ -1,128 +1,5 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 1;
/// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory());
/// Production opener: encrypted on-device file (tech-design.md §8).
static Future<AppDatabase> open() async {
final db = AppDatabase(_openEncryptedExecutor());
// Force open so missing SQLCipher SO fails here (caller can fall back).
await db.customSelect('SELECT 1').get();
return db;
}
Future<List<String>> loadToneSamples() async {
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
return rows.map((r) => r.sampleText).toList();
}
Future<void> replaceToneSamples(List<String> samples) async {
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
await delete(toneSamples).go();
for (var i = 0; i < samples.length; i++) {
await into(toneSamples).insert(
ToneSamplesCompanion.insert(
id: 'tone_${i}_$now',
sampleText: samples[i],
createdAtMs: now,
sortOrder: i,
),
);
}
});
}
Future<String?> getKv(String key) async {
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> setKv(String key, String value) async {
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
}
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 passphrase = await _loadOrCreatePassphrase();
// Background isolate does not inherit open.overrideFor re-apply there.
final token = RootIsolateToken.instance;
return NativeDatabase.createInBackground(
file,
isolateSetup: () async {
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
await _configureSqlCipherOpen();
},
setup: (rawDb) {
// SQLCipher key must be set before any other statement.
final escaped = passphrase.replaceAll("'", "''");
rawDb.execute("PRAGMA key = '$escaped'");
rawDb.config.doubleQuotedStringLiterals = false;
},
);
});
}
Future<void> _configureSqlCipherOpen() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
} else if (Platform.isLinux) {
// Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
} else if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
}
// iOS/macOS: sqlcipher_flutter_libs links into the process.
}
Future<String> _loadOrCreatePassphrase() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _kDbPassphrase);
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
await storage.write(key: _kDbPassphrase, value: passphrase);
return passphrase;
}
/// Platform-specific local database entrypoint.
///
/// - IO (Android / iOS / Linux / ): Drift + SQLCipher (`app_database_native.dart`)
/// - Web (Chrome): in-memory stub (`app_database_web.dart`) FFI cannot compile
export 'app_database_native.dart' if (dart.library.html) 'app_database_web.dart';

View File

@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_database.dart';
part of 'app_database_native.dart';
// ignore_for_file: type=lint
class $ToneSamplesTable extends ToneSamples

View File

@ -0,0 +1,131 @@
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';
import 'tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [ToneSamples, LocalKv])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e, {this.encrypted = false});
/// True when backed by on-device SQLCipher (not memory / web stub).
final bool encrypted;
@override
int get schemaVersion => 1;
/// In-memory DB for unit tests (no SQLCipher / filesystem).
factory AppDatabase.memory() => AppDatabase(NativeDatabase.memory(), encrypted: false);
/// Production opener: encrypted on-device file (tech-design.md §8).
static Future<AppDatabase> open() async {
final db = AppDatabase(_openEncryptedExecutor(), encrypted: true);
// Force open so missing SQLCipher SO fails here (caller can fall back).
await db.customSelect('SELECT 1').get();
return db;
}
Future<List<String>> loadToneSamples() async {
final rows = await (select(toneSamples)..orderBy([(t) => OrderingTerm.asc(t.sortOrder)])).get();
return rows.map((r) => r.sampleText).toList();
}
Future<void> replaceToneSamples(List<String> samples) async {
final now = DateTime.now().millisecondsSinceEpoch;
await transaction(() async {
await delete(toneSamples).go();
for (var i = 0; i < samples.length; i++) {
await into(toneSamples).insert(
ToneSamplesCompanion.insert(
id: 'tone_${i}_$now',
sampleText: samples[i],
createdAtMs: now,
sortOrder: i,
),
);
}
});
}
Future<String?> getKv(String key) async {
final row = await (select(localKv)..where((t) => t.key.equals(key))).getSingleOrNull();
return row?.value;
}
Future<void> setKv(String key, String value) async {
await into(localKv).insertOnConflictUpdate(LocalKvCompanion.insert(key: key, value: value));
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
}
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 passphrase = await _loadOrCreatePassphrase();
// Background isolate does not inherit open.overrideFor re-apply there.
final token = RootIsolateToken.instance;
return NativeDatabase.createInBackground(
file,
isolateSetup: () async {
if (token != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
}
await _configureSqlCipherOpen();
},
setup: (rawDb) {
// SQLCipher key must be set before any other statement.
final escaped = passphrase.replaceAll("'", "''");
rawDb.execute("PRAGMA key = '$escaped'");
rawDb.config.doubleQuotedStringLiterals = false;
},
);
});
}
Future<void> _configureSqlCipherOpen() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
open.overrideFor(OperatingSystem.android, openCipherOnAndroid);
} else if (Platform.isLinux) {
// Desktop/dev: try SQLCipher SO; fall back is handled by open() failure upstream.
open.overrideFor(OperatingSystem.linux, () => DynamicLibrary.open('libsqlcipher.so'));
} else if (Platform.isWindows) {
open.overrideFor(OperatingSystem.windows, () => DynamicLibrary.open('sqlcipher.dll'));
}
// iOS/macOS: sqlcipher_flutter_libs links into the process.
}
Future<String> _loadOrCreatePassphrase() async {
const storage = FlutterSecureStorage();
final existing = await storage.read(key: _kDbPassphrase);
if (existing != null && existing.isNotEmpty) return existing;
final rand = Random.secure();
final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
final passphrase = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
await storage.write(key: _kDbPassphrase, value: passphrase);
return passphrase;
}

View File

@ -0,0 +1,42 @@
/// Web/Chrome stub for [AppDatabase].
///
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an
/// in-tab memory map so UI + API flows still work. Persistence is not encrypted
/// and is lost on refresh (web is a preview surface, not the release target).
class AppDatabase {
AppDatabase({this.encrypted = false});
/// Always false on web there is no SQLCipher path.
final bool encrypted;
final Map<String, String> _kv = {};
List<String> _toneSamples = [];
factory AppDatabase.memory() => AppDatabase(encrypted: false);
static Future<AppDatabase> open() async => AppDatabase(encrypted: false);
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
Future<void> replaceToneSamples(List<String> samples) async {
_toneSamples = List<String>.from(samples);
}
Future<String?> getKv(String key) async => _kv[key];
Future<void> setKv(String key, String value) async {
_kv[key] = value;
}
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
final v = await getKv(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBoolKv(String key, bool value) async {
await setKv(key, value ? '1' : '0');
}
Future<void> close() async {}
}

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -5,12 +8,41 @@ import 'screens/conversation_list_screen.dart';
import 'screens/onboarding_tone_screen.dart';
import 'screens/signup_screen.dart';
import 'state/session_state.dart';
import 'theme/app_theme.dart';
Future<void> 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 {
@ -24,13 +56,10 @@ class BunsinApp extends StatelessWidget {
value: session,
child: MaterialApp(
title: '분신',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1F6F5B),
brightness: Brightness.light,
),
useMaterial3: true,
),
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
// Twin Shadow phase-1: light only (dark kept in AppTheme for later).
themeMode: ThemeMode.light,
home: Consumer<SessionState>(
builder: (context, s, _) {
if (s.user == null) return const SignupScreen();

View File

@ -49,36 +49,50 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
super.dispose();
}
static const _levelDescriptions = {
AutonomyLevel.L0: '분신이 초안만 만들고, 발송은 항상 직접 합니다.',
AutonomyLevel.L1: '분신이 초안을 만들면 검토·수정 후 승인해야 보내집니다.',
AutonomyLevel.L2: '아래 화이트리스트 주제는 승인 없이 자동으로 보내집니다.',
};
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('자율성 설정')),
body: ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('데이터 흐름'),
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
},
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('데이터 흐름'),
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
},
),
const Divider(height: 1, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.devices),
title: const Text('로그인 세션'),
subtitle: const Text('멀티 디바이스 세션 목록'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
},
),
],
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.devices),
title: const Text('로그인 세션'),
subtitle: const Text('멀티 디바이스 세션 목록'),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
},
),
const Divider(height: 32),
Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
const SizedBox(height: 24),
Text('전역 자율성 레벨', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
SegmentedButton<AutonomyLevel>(
segments: const [
ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'),
@ -88,23 +102,37 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
selected: {session.autonomyLevel},
onSelectionChanged: (s) => session.setAutonomy(s.first),
),
const SizedBox(height: 8),
Text(
'기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.',
style: Theme.of(context).textTheme.bodySmall,
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
_levelDescriptions[session.autonomyLevel] ?? '',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
],
),
),
const Divider(height: 32),
Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
const SizedBox(height: 28),
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: _keyword,
decoration: const InputDecoration(
labelText: '주제 키워드',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: '주제 키워드'),
),
),
const SizedBox(width: 8),
@ -128,24 +156,37 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
const SizedBox(height: 12),
if (_loading)
const Center(child: CircularProgressIndicator())
else
..._rules.map(
(r) => ListTile(
title: Text(r.topicKeyword),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () async {
if (session.user == null) return;
await session.api.deleteWhitelist(session.user!.id, r.id);
setState(() => _rules = _rules.where((x) => x.id != r.id).toList());
},
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_rules.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'아직 화이트리스트 주제가 없습니다.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final r in _rules)
InputChip(
label: Text(r.topicKeyword),
onDeleted: () async {
if (session.user == null) return;
await session.api.deleteWhitelist(session.user!.id, r.id);
setState(() => _rules = _rules.where((x) => x.id != r.id).toList());
},
),
],
),
],
),

View File

@ -205,6 +205,18 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _veto() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('거부권을 쓸까요?'),
content: const Text('이 대화방에서 분신 자동응대가 즉시 중단됩니다. 이후에는 다시 켤 수 없습니다.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('거부권 사용')),
],
),
);
if (ok != true || !mounted) return;
final session = context.read<SessionState>();
try {
await session.api.vetoConversation(widget.conversationId);
@ -229,74 +241,86 @@ class _ChatScreenState extends State<ChatScreen> {
if (draft == null) return const SizedBox.shrink();
if (draft.isEscalate) {
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
),
],
),
return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
);
}
return Material(
elevation: 2,
color: theme.colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
Icon(Icons.warning_amber_rounded, size: 18, color: theme.colorScheme.onErrorContainer),
const SizedBox(width: 6),
Text(
'L1 승인 — 수정 후 보내기',
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
'직접 확인 필요',
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _draftEdit,
minLines: 2,
maxLines: 5,
decoration: const InputDecoration(
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.white70,
),
const SizedBox(height: 6),
Text(
draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
const SizedBox(height: 8),
Row(
children: [
TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')),
const Spacer(),
FilledButton.tonal(
onPressed: _busy ? null : _requestDraft,
child: const Text('다시 초안'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _busy ? null : _sendTwinApproved,
child: const Text('승인하고 보내기'),
),
],
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
),
],
),
);
}
return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
Text(
'L1 승인 — 수정 후 보내기',
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
),
],
),
const SizedBox(height: 10),
TextField(
controller: _draftEdit,
minLines: 2,
maxLines: 5,
style: TextStyle(color: theme.colorScheme.onSurface),
decoration: InputDecoration(fillColor: theme.colorScheme.surface),
),
const SizedBox(height: 10),
Row(
children: [
TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')),
const Spacer(),
FilledButton.tonal(
onPressed: _busy ? null : _requestDraft,
child: const Text('다시 초안'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _busy ? null : _sendTwinApproved,
child: const Text('승인하고 보내기'),
),
],
),
],
),
);
}
@ -306,17 +330,23 @@ class _ChatScreenState extends State<ChatScreen> {
final session = context.watch<SessionState>();
final me = session.user?.id;
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
actions: [
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
IconButton(
tooltip: '거부권 (분신 자동응대 중단)',
onPressed: _busy ? null : _veto,
icon: const Icon(Icons.block),
),
],
),
body: Column(
children: [
if (_banner != null)
MaterialBanner(
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
content: Text(_banner!),
actions: [
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
@ -325,35 +355,40 @@ class _ChatScreenState extends State<ChatScreen> {
Expanded(
child: _loadingHistory
? const Center(child: CircularProgressIndicator())
: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, i) {
final m = _messages[i];
return MessageBubble(
message: m,
isMine: me != null && m.senderId == me,
onRetract: m.isTwin ? () => _retract(m) : null,
);
},
),
: _messages.isEmpty
? Center(
child: Text(
'아직 메시지가 없습니다. 첫 메시지를 보내 보세요.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
)
: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _messages.length,
itemBuilder: (context, i) {
final m = _messages[i];
return MessageBubble(
message: m,
isMine: me != null && m.senderId == me,
onRetract: m.isTwin ? () => _retract(m) : null,
);
},
),
),
_buildL1Panel(context),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _input,
minLines: 1,
maxLines: 4,
decoration: const InputDecoration(
hintText: '메시지',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(hintText: '메시지'),
),
),
const SizedBox(width: 8),

View File

@ -54,7 +54,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()),
decoration: const InputDecoration(labelText: '표시 이름'),
),
const SizedBox(height: 12),
TextField(
@ -63,13 +63,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
decoration: const InputDecoration(
labelText: '상대 사용자 ID (선택)',
helperText: '대화 시작에 필요',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: noteCtrl,
decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()),
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
),
],
),
@ -118,54 +117,102 @@ class _ContactsScreenState extends State<ContactsScreen> {
}
}
Future<void> _delete(Contact c) async {
final session = context.read<SessionState>();
if (session.user == null) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('연락처 삭제'),
content: Text('${c.displayName}을(를) 삭제할까요?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
FilledButton.tonal(onPressed: () => Navigator.pop(ctx, true), child: const Text('삭제')),
],
),
);
if (ok != true) return;
await session.api.deleteContact(session.user!.id, c.id);
if (!mounted) return;
setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('연락처')),
floatingActionButton: FloatingActionButton(
onPressed: _showAddDialog,
tooltip: '연락처 추가',
child: const Icon(Icons.person_add_alt_1),
),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
: ListView(
padding: const EdgeInsets.symmetric(vertical: 4),
children: [
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
),
if (_contacts.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'),
Padding(
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
child: Column(
children: [
Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text('연락처가 없습니다', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
for (final c in _contacts)
ListTile(
leading: const CircleAvatar(child: Icon(Icons.person_outline)),
title: Text(c.displayName),
subtitle: Text(
[
if (c.contactUserId != null) '사용자 #${c.contactUserId}',
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
].join(' · '),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: ListTile(
leading: CircleAvatar(
backgroundColor: theme.colorScheme.secondaryContainer,
child: Text(
c.displayName.isEmpty ? '?' : c.displayName.substring(0, 1),
style: TextStyle(
color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
title: Text(c.displayName, style: theme.textTheme.titleSmall),
subtitle: Text(
[
if (c.contactUserId != null) '사용자 #${c.contactUserId}',
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
].join(' · '),
style: theme.textTheme.bodySmall,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (c.contactUserId != null)
TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
IconButton(
tooltip: '삭제',
icon: const Icon(Icons.delete_outline, size: 20),
onPressed: () => _delete(c),
),
],
),
),
trailing: c.contactUserId == null
? null
: TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
onLongPress: () async {
final session = context.read<SessionState>();
if (session.user == null) return;
await session.api.deleteContact(session.user!.id, c.id);
setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList());
},
),
const Padding(
padding: EdgeInsets.all(16),
child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center),
),
const SizedBox(height: 72),
],
),
),

View File

@ -58,7 +58,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
decoration: const InputDecoration(
labelText: '상대 사용자 ID',
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
border: OutlineInputBorder(),
),
),
actions: [
@ -84,14 +83,33 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
}
}
Color _avatarColor(BuildContext context, int seed) {
final scheme = Theme.of(context).colorScheme;
final palette = [scheme.primaryContainer, scheme.tertiaryContainer, scheme.secondaryContainer];
return palette[seed % palette.length];
}
Color _onAvatarColor(BuildContext context, int seed) {
final scheme = Theme.of(context).colorScheme;
final palette = [scheme.onPrimaryContainer, scheme.onTertiaryContainer, scheme.onSecondaryContainer];
return palette[seed % palette.length];
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
final theme = Theme.of(context);
final me = session.user?.id;
return Scaffold(
appBar: AppBar(
title: Text('분신 · ${session.user?.displayName ?? ''}'),
title: Text(
'분신',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
),
),
actions: [
IconButton(
tooltip: '사후 알림',
@ -108,68 +126,123 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
},
icon: const Icon(Icons.contacts_outlined),
),
IconButton(
tooltip: '말투 샘플',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen()));
},
icon: const Icon(Icons.record_voice_over_outlined),
),
IconButton(
tooltip: '자율성 설정',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AutonomySettingsScreen()),
);
},
icon: const Icon(Icons.tune),
PopupMenuButton<VoidCallback>(
tooltip: '더보기',
icon: const Icon(Icons.more_vert),
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen())),
child: const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.record_voice_over_outlined),
title: Text('말투 샘플'),
),
),
PopupMenuItem(
value: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const AutonomySettingsScreen())),
child: const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.tune),
title: Text('자율성 설정'),
),
),
],
),
const SizedBox(width: 4),
],
),
floatingActionButton: FloatingActionButton(
floatingActionButton: FloatingActionButton.extended(
onPressed: _createConversation,
child: const Icon(Icons.chat),
icon: const Icon(Icons.chat),
label: const Text('새 대화'),
),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
: ListView(
padding: const EdgeInsets.symmetric(vertical: 4),
children: [
Padding(
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,
fontWeight: FontWeight.w600,
),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
),
if (_rooms.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
Padding(
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
child: Column(
children: [
Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text(
'대화방이 없습니다',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
for (final room in _rooms)
ListTile(
leading: CircleAvatar(
child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline),
),
title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)),
subtitle: Text(
[
'ID ${room.id}',
if (room.twinDisabledByPeer) '상대가 분신 거부',
].join(' · '),
),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: room.id,
title: me == null ? null : room.titleFor(me),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _avatarColor(context, room.id),
child: Icon(
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
color: _onAvatarColor(context, room.id),
),
);
await _load();
},
),
title: Text(
me == null ? '대화방 #${room.id}' : room.titleFor(me),
style: theme.textTheme.titleSmall,
),
subtitle: room.twinDisabledByPeer
? Row(
children: [
Icon(Icons.block, size: 13, color: theme.colorScheme.error),
const SizedBox(width: 4),
Text(
'상대가 분신을 거부함',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
),
],
)
: Text('대화방 ID ${room.id}', style: theme.textTheme.bodySmall),
trailing: const Icon(Icons.chevron_right, size: 20),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: room.id,
title: me == null ? null : room.titleFor(me),
),
),
);
await _load();
},
),
),
const SizedBox(height: 72),
],
),
),

View File

@ -2,11 +2,56 @@ 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 {
const DataFlowScreen({super.key});
Widget _section(
BuildContext context, {
required IconData icon,
required Color tint,
required String title,
required String body,
}) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(color: tint.withValues(alpha: 0.15), shape: BoxShape.circle),
child: Icon(icon, size: 18, color: tint),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(
body,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final session = context.watch<SessionState>();
@ -16,45 +61,49 @@ class DataFlowScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: const Text('데이터 흐름')),
body: ListView(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.all(16),
children: [
Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
_section(
context,
icon: Icons.lock_outline,
tint: TwinTokens.forest,
title: '기기 안에만 둡니다',
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
),
const SizedBox(height: 12),
Card(
margin: const EdgeInsets.only(bottom: 16),
child: ListTile(
leading: Icon(Icons.record_voice_over_outlined, color: theme.colorScheme.primary),
title: Text('말투 샘플 ${samples.length}'),
subtitle: Text(
[
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
session.localDbEncrypted
? '저장: drift + SQLCipher(암호화)'
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
].join('\n'),
),
isThreeLine: true,
),
),
const SizedBox(height: 24),
Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n'
'채팅 릴레이: 보낸 메시지 본문.\n'
'계정: 표시 이름·초대 코드·세션 토큰.\n'
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
_section(
context,
icon: Icons.cloud_upload_outlined,
tint: TwinTokens.twinMark,
title: '서버로 보낼 수 있는 것',
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n'
'계정: 표시 이름·초대 코드·세션 토큰\n'
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후',
),
const SizedBox(height: 24),
Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
'관계 메모의 자동 클라우드 분석.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
_section(
context,
icon: Icons.block,
tint: theme.colorScheme.error,
title: '보내지 않는 것',
body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
'관계 메모의 자동 클라우드 분석',
),
],
),

View File

@ -51,11 +51,12 @@ class _InboxScreenState extends State<InboxScreen> {
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
: ListView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
child: Text(
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
@ -64,36 +65,89 @@ class _InboxScreenState extends State<InboxScreen> {
),
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
),
if (_logs.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text('새 알림이 없습니다.'),
Padding(
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
child: Column(
children: [
Icon(Icons.notifications_none, size: 40, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text('새 알림이 없습니다', style: theme.textTheme.titleMedium),
],
),
),
for (final log in _logs)
ListTile(
leading: Icon(
log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error,
),
title: Text(log.reason.isEmpty ? '에스컬레이션' : log.reason),
subtitle: Text(
[
'대화방 #${log.conversationId}',
if (log.messageSnippet.isNotEmpty) log.messageSnippet,
log.createdAt.toLocal().toString().split('.').first,
].join('\n'),
),
isThreeLine: true,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(conversationId: log.conversationId),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(conversationId: log.conversationId),
),
);
},
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 18,
backgroundColor: log.resolved
? theme.colorScheme.primaryContainer
: theme.colorScheme.errorContainer,
child: Icon(
log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
size: 18,
color: log.resolved
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
log.reason.isEmpty ? '에스컬레이션' : log.reason,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'대화방 #${log.conversationId}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
if (log.messageSnippet.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
log.messageSnippet,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
],
const SizedBox(height: 6),
Text(
log.createdAt.toLocal().toString().split('.').first,
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.outline),
),
],
),
),
],
),
),
);
},
),
),
),
],
),

View File

@ -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});
@ -46,46 +48,105 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
@override
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<SessionState>().skipToneOnboarding(),
child: const Text('나중에'),
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
for (var i = 0; i < _samples.length; i++) ...[
TextField(
controller: _samples[i],
maxLines: 2,
decoration: InputDecoration(
labelText: '샘플 ${i + 1}',
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
border: const OutlineInputBorder(),
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<SessionState>().skipToneOnboarding(),
child: const Text('나중에'),
),
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
children: [
TwinFadeUp(
child: Text(
'말투',
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: 8),
FilledButton(
onPressed: () => _save(markDone: true),
child: const Text('저장하고 시작'),
),
],
),
),
),
);

View File

@ -72,32 +72,53 @@ class _SessionsScreenState extends State<SessionsScreen> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('로그인 세션')),
body: RefreshIndicator(
onRefresh: _load,
child: _loading
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Text(
'이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
),
for (final s in _sessions)
ListTile(
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
trailing: IconButton(
tooltip: '세션 종료',
icon: const Icon(Icons.logout),
onPressed: () => _revoke(s),
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
leading: CircleAvatar(
backgroundColor: s['is_current'] == true
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
child: Icon(
s['is_current'] == true ? Icons.smartphone : Icons.devices_other,
color: s['is_current'] == true
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant,
),
),
title: Text(
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}',
style: theme.textTheme.titleSmall,
),
subtitle: Text('만료: ${s['expires_at'] ?? ''}', style: theme.textTheme.bodySmall),
trailing: IconButton(
tooltip: '세션 종료',
icon: const Icon(Icons.logout),
onPressed: () => _revoke(s),
),
),
),
],

View File

@ -1,7 +1,11 @@
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';
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@ -21,55 +25,189 @@ 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>();
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
Text('분신', style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
Text(
'초대 코드로 클로즈드 베타에 참여합니다.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
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,
),
),
const SizedBox(height: 32),
TextField(
controller: _invite,
decoration: const InputDecoration(
labelText: '초대 코드',
border: OutlineInputBorder(),
),
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _name,
decoration: const InputDecoration(
labelText: '표시 이름',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
),
if (session.error != null) ...[
const SizedBox(height: 12),
Text(session.error!, style: TextStyle(color: Theme.of(context).colorScheme.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: 2),
TwinFadeUp(
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: [
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 Spacer(),
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('시작하기'),
),
),
),
),
);
}
}
/// 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)),
),
],
),
@ -78,3 +216,31 @@ class _SignupScreenState extends State<SignupScreen> {
);
}
}
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

@ -60,11 +60,12 @@ class SessionState extends ChangeNotifier {
Future<AppDatabase> _openDb() async {
try {
final db = await AppDatabase.open();
localDbEncrypted = true;
localDbEncrypted = db.encrypted;
return db;
} catch (e) {
// Linux CI / hosts without libsqlcipher.so fall back to memory so the
// app still boots; Android release path uses SQLCipher.
// Web uses an in-memory stub via conditional import (no FFI).
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
localDbEncrypted = false;
return AppDatabase.memory();

View File

@ -0,0 +1,195 @@
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
}
class AppTheme {
AppTheme._();
static Color twinAccent(Brightness brightness) =>
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 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.standard,
textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0.5,
centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: scheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
margin: EdgeInsets.zero,
),
listTileTheme: ListTileThemeData(
iconColor: scheme.onSurfaceVariant,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
dividerTheme: DividerThemeData(color: scheme.outlineVariant, space: 24, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
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(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.8)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.primary, width: 1.6),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.2),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.error, width: 1.6),
),
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: 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)),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
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,
backgroundColor: scheme.surfaceContainerHighest,
labelStyle: TextStyle(color: scheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
),
);
}
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),
);
}
}

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import '../models/models.dart';
import '../theme/app_theme.dart';
/// Twin messages use a dashed border + badge (PRD §3.1 ).
/// Twin messages get a dashed border + badge (PRD §3.1 ) so an
/// auto-sent bubble never reads as something the human actually typed.
class MessageBubble extends StatelessWidget {
const MessageBubble({
super.key,
@ -15,90 +17,146 @@ class MessageBubble extends StatelessWidget {
final bool isMine;
final VoidCallback? onRetract;
static String _time(DateTime dt) {
final local = dt.toLocal();
final h = local.hour.toString().padLeft(2, '0');
final m = local.minute.toString().padLeft(2, '0');
return '$h:$m';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final twin = message.isTwin;
final bg = isMine
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest;
final retracted = message.retracted;
final accent = AppTheme.twinAccent(theme.brightness);
final Color bg;
final Color fg;
if (retracted) {
bg = scheme.surfaceContainerHigh;
fg = scheme.onSurfaceVariant;
} else if (isMine) {
bg = scheme.primaryContainer;
fg = scheme.onPrimaryContainer;
} else {
bg = scheme.surfaceContainerHighest;
fg = scheme.onSurface;
}
final radius = BorderRadius.only(
topLeft: const Radius.circular(18),
topRight: const Radius.circular(18),
bottomLeft: Radius.circular(isMine ? 18 : 4),
bottomRight: Radius.circular(isMine ? 4 : 18),
);
final bubble = Container(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(14),
border: twin
? Border.all(color: theme.colorScheme.tertiary, width: 1.5, strokeAlign: BorderSide.strokeAlignOutside)
: null,
),
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
decoration: BoxDecoration(color: bg, borderRadius: radius),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (twin)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'분신',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.tertiary,
fontWeight: FontWeight.w700,
padding: const EdgeInsets.only(bottom: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome, size: 13, color: accent),
const SizedBox(width: 4),
Text(
'분신',
style: theme.textTheme.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w800,
letterSpacing: 0.2,
),
),
],
),
),
if (retracted)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.replay, size: 14, color: fg),
const SizedBox(width: 4),
Text(
'되돌린 메시지',
style: theme.textTheme.bodyMedium?.copyWith(color: fg, fontStyle: FontStyle.italic),
),
),
],
)
else
Text(
message.text,
style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.35),
),
const SizedBox(height: 4),
Text(
message.retracted ? '(되돌린 메시지)' : message.text,
style: theme.textTheme.bodyMedium?.copyWith(
fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal,
color: message.retracted ? theme.disabledColor : null,
),
_time(message.createdAt),
style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10),
),
if (twin && isMine && !message.retracted && onRetract != null)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onRetract,
child: const Text('되돌리기'),
),
),
],
),
);
// Dashed look for twin: overlay a custom painter border when twin.
if (!twin) {
return Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: bubble,
);
}
final content = Column(
crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
twin
? CustomPaint(
painter: _DashedRRectPainter(color: accent, radius: radius),
child: bubble,
)
: bubble,
if (twin && isMine && !retracted && onRetract != null)
Padding(
padding: const EdgeInsets.only(top: 2, right: 4, left: 4),
child: TextButton.icon(
onPressed: onRetract,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: scheme.onSurfaceVariant,
textStyle: const TextStyle(fontSize: 12),
),
icon: const Icon(Icons.undo, size: 14),
label: const Text('되돌리기'),
),
),
],
);
return Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: CustomPaint(
painter: _DashedRRectPainter(color: theme.colorScheme.tertiary),
child: bubble,
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
child: Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: content,
),
);
}
}
class _DashedRRectPainter extends CustomPainter {
_DashedRRectPainter({required this.color});
_DashedRRectPainter({required this.color, required this.radius});
final Color color;
final BorderRadius radius;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(1, 1, size.width - 2, size.height - 2),
const Radius.circular(14),
);
..strokeWidth = 1.4;
final rrect = radius.toRRect(Rect.fromLTWH(0.7, 0.7, size.width - 1.4, size.height - 1.4));
final path = Path()..addRRect(rrect);
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
canvas.drawPath(dashed, paint);
@ -119,5 +177,6 @@ class _DashedRRectPainter extends CustomPainter {
}
@override
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color;
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) =>
oldDelegate.color != color || oldDelegate.radius != radius;
}

View File

@ -0,0 +1,164 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Full-bleed Twin Shadow atmosphere: mistpaper gradient + overlapping
/// silhouettes (the "second self"). No badges, chips, or promo overlays.
class TwinHeroBackdrop extends StatefulWidget {
const TwinHeroBackdrop({super.key, required this.child});
final Widget child;
@override
State<TwinHeroBackdrop> createState() => _TwinHeroBackdropState();
}
class _TwinHeroBackdropState extends State<TwinHeroBackdrop> with SingleTickerProviderStateMixin {
late final AnimationController _drift;
@override
void initState() {
super.initState();
_drift = AnimationController(vsync: this, duration: const Duration(seconds: 14))..repeat(reverse: true);
}
@override
void dispose() {
_drift.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _drift,
builder: (context, _) {
final t = Curves.easeInOut.transform(_drift.value);
return Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(-0.2 + t * 0.15, -1),
end: Alignment(0.3 - t * 0.1, 1.1),
colors: const [
TwinTokens.glow,
TwinTokens.mist,
TwinTokens.paper,
],
stops: const [0.0, 0.42, 1.0],
),
),
),
CustomPaint(
painter: _TwinSilhouettePainter(phase: t),
),
widget.child,
],
);
},
);
}
}
class _TwinSilhouettePainter extends CustomPainter {
_TwinSilhouettePainter({required this.phase});
final double phase;
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final paintA = Paint()..color = TwinTokens.forest.withValues(alpha: 0.07 + phase * 0.02);
final paintB = Paint()..color = TwinTokens.ink.withValues(alpha: 0.05 + (1 - phase) * 0.02);
// Soft overlapping ovals a person and their shadow-self.
final a = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(w * 0.72 + phase * 8, h * 0.22),
width: w * 0.55,
height: h * 0.42,
),
const Radius.circular(120),
);
final b = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(w * 0.78 + phase * 4, h * 0.26),
width: w * 0.48,
height: h * 0.38,
),
const Radius.circular(120),
);
canvas.drawRRect(a, paintA);
canvas.drawRRect(b, paintB);
// Thin crescent arc suggesting a second outline.
final arcPaint = Paint()
..color = TwinTokens.forest.withValues(alpha: 0.12)
..style = PaintingStyle.stroke
..strokeWidth = 1.4;
canvas.drawArc(
Rect.fromCenter(center: Offset(w * 0.78, h * 0.24), width: w * 0.42, height: h * 0.34),
-math.pi * 0.2,
math.pi * 1.1,
false,
arcPaint,
);
}
@override
bool shouldRepaint(covariant _TwinSilhouettePainter oldDelegate) => oldDelegate.phase != phase;
}
/// Brand wordmark entrance fade + slight rise.
class TwinFadeUp extends StatefulWidget {
const TwinFadeUp({
super.key,
required this.child,
this.delay = Duration.zero,
this.duration = const Duration(milliseconds: 700),
});
final Widget child;
final Duration delay;
final Duration duration;
@override
State<TwinFadeUp> createState() => _TwinFadeUpState();
}
class _TwinFadeUpState extends State<TwinFadeUp> with SingleTickerProviderStateMixin {
late final AnimationController _c;
late final Animation<double> _opacity;
late final Animation<Offset> _offset;
@override
void initState() {
super.initState();
_c = AnimationController(vsync: this, duration: widget.duration);
_opacity = CurvedAnimation(parent: _c, curve: Curves.easeOutCubic);
_offset = Tween(begin: const Offset(0, 0.06), end: Offset.zero)
.animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic));
Future<void>.delayed(widget.delay, () {
if (mounted) _c.forward();
});
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _opacity,
child: SlideTransition(position: _offset, child: widget.child),
);
}
}

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

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

BIN
mobile/web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

41
mobile/web/index.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="bunsin_mobile">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>분신</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<div id="bunsin-boot" style="font-family: Manrope, system-ui, sans-serif; padding: 24px; color: #1F6F5B;">
분신 로딩 중…
</div>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

35
mobile/web/manifest.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "bunsin_mobile",
"short_name": "bunsin_mobile",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}