Compare commits
No commits in common. "17b10a0e8c6c623d2ef29b40acbdd3102190a710" and "e9a7e420f9ed58c39bb396ecfc8b5fc8e2eea5cd" have entirely different histories.
17b10a0e8c
...
e9a7e420f9
|
|
@ -5,9 +5,6 @@ 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=
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@ When documents disagree, follow this order:
|
|||
|
||||
Notes:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
||||
current working answers.
|
||||
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md).
|
|||
|
||||
Quick context:
|
||||
|
||||
- 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).
|
||||
- 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: 제안).
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDemoInviteReusableForMultipleSignups(t *testing.T) {
|
||||
t.Setenv("ALLOW_DEMO_INVITE", "1")
|
||||
server, db := setupTestServer(t)
|
||||
seedDemoInvite(db)
|
||||
|
||||
demoResp, err := http.Get(server.URL + "/demo")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /demo: %v", err)
|
||||
}
|
||||
defer demoResp.Body.Close()
|
||||
var demo map[string]any
|
||||
if err := json.NewDecoder(demoResp.Body).Decode(&demo); err != nil {
|
||||
t.Fatalf("decode demo: %v", err)
|
||||
}
|
||||
if demo["demo_invite_code"] != demoInviteCode {
|
||||
t.Fatalf("demo code: %v", demo["demo_invite_code"])
|
||||
}
|
||||
|
||||
a := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
||||
InviteCode: demoInviteCode,
|
||||
DisplayName: "테스터A",
|
||||
})
|
||||
defer a.Body.Close()
|
||||
aBody, _ := io.ReadAll(a.Body)
|
||||
if a.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first demo signup: %d %s", a.StatusCode, aBody)
|
||||
}
|
||||
|
||||
b := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
||||
InviteCode: demoInviteCode,
|
||||
DisplayName: "테스터B",
|
||||
})
|
||||
defer b.Body.Close()
|
||||
bBody, _ := io.ReadAll(b.Body)
|
||||
if b.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second demo signup should reuse code: %d %s", b.StatusCode, bBody)
|
||||
}
|
||||
|
||||
var outA, outB map[string]any
|
||||
_ = json.Unmarshal(aBody, &outA)
|
||||
_ = json.Unmarshal(bBody, &outB)
|
||||
if outA["id"] == outB["id"] {
|
||||
t.Fatalf("expected distinct users, got %#v %#v", outA, outB)
|
||||
}
|
||||
}
|
||||
|
|
@ -49,32 +49,8 @@ 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"})
|
||||
|
|
@ -83,7 +59,6 @@ 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) {
|
||||
|
|
@ -163,52 +138,31 @@ 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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
user := User{InviteCode: storedInvite, DisplayName: req.DisplayName}
|
||||
user := User{InviteCode: req.InviteCode, DisplayName: req.DisplayName}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
||||
|
||||
if !demo {
|
||||
now := time.Now()
|
||||
invite.UsedAt = &now
|
||||
invite.UsedByUserID = &user.ID
|
||||
db.Save(&invite)
|
||||
}
|
||||
now := time.Now()
|
||||
invite.UsedAt = &now
|
||||
invite.UsedByUserID = &user.ID
|
||||
db.Save(&invite)
|
||||
|
||||
session, err := createSession(db, user.ID)
|
||||
if err != nil {
|
||||
|
|
@ -696,7 +650,6 @@ func main() {
|
|||
return
|
||||
}
|
||||
db := openDB()
|
||||
seedDemoInvite(db)
|
||||
relay := newConnectionManager()
|
||||
ai := newAIServiceClient()
|
||||
r := setupRouter(db, relay, ai)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ migration:
|
|||
- platform: root
|
||||
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
- platform: web
|
||||
- platform: android
|
||||
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ 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';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,128 @@
|
|||
/// 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';
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_database_native.dart';
|
||||
part of 'app_database.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
class $ToneSamplesTable extends ToneSamples
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/// 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 {}
|
||||
}
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -8,41 +5,12 @@ 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();
|
||||
|
||||
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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final session = SessionState();
|
||||
await session.restore();
|
||||
runApp(BunsinApp(session: session));
|
||||
}
|
||||
|
||||
class BunsinApp extends StatelessWidget {
|
||||
|
|
@ -56,10 +24,13 @@ class BunsinApp extends StatelessWidget {
|
|||
value: session,
|
||||
child: MaterialApp(
|
||||
title: '분신',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
// Twin Shadow phase-1: light only (dark kept in AppTheme for later).
|
||||
themeMode: ThemeMode.light,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF1F6F5B),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Consumer<SessionState>(
|
||||
builder: (context, s, _) {
|
||||
if (s.user == null) return const SignupScreen();
|
||||
|
|
|
|||
|
|
@ -49,50 +49,36 @@ 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.fromLTRB(16, 8, 16, 32),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
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.privacy_tip_outlined),
|
||||
title: const Text('데이터 흐름'),
|
||||
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('전역 자율성 레벨', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
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),
|
||||
SegmentedButton<AutonomyLevel>(
|
||||
segments: const [
|
||||
ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'),
|
||||
|
|
@ -102,37 +88,23 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
|||
selected: {session.autonomyLevel},
|
||||
onSelectionChanged: (s) => session.setAutonomy(s.first),
|
||||
),
|
||||
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 SizedBox(height: 8),
|
||||
Text(
|
||||
'기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 32),
|
||||
Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _keyword,
|
||||
decoration: const InputDecoration(labelText: '주제 키워드'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: '주제 키워드',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -156,37 +128,24 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
|||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_loading)
|
||||
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),
|
||||
),
|
||||
)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
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());
|
||||
},
|
||||
),
|
||||
],
|
||||
..._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());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -205,18 +205,6 @@ 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);
|
||||
|
|
@ -241,86 +229,74 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
if (draft == null) return const SizedBox.shrink();
|
||||
|
||||
if (draft.isEscalate) {
|
||||
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(
|
||||
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 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.warning_amber_rounded, size: 18, color: theme.colorScheme.onErrorContainer),
|
||||
Icon(Icons.auto_awesome, size: 18, color: theme.colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'직접 확인 필요',
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer),
|
||||
'L1 승인 — 수정 후 보내기',
|
||||
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text,
|
||||
style: TextStyle(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: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
|
||||
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('승인하고 보내기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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('승인하고 보내기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -330,23 +306,17 @@ 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: [
|
||||
IconButton(
|
||||
tooltip: '거부권 (분신 자동응대 중단)',
|
||||
onPressed: _busy ? null : _veto,
|
||||
icon: const Icon(Icons.block),
|
||||
),
|
||||
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
|
||||
],
|
||||
),
|
||||
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('닫기')),
|
||||
|
|
@ -355,40 +325,35 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Expanded(
|
||||
child: _loadingHistory
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _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,
|
||||
);
|
||||
},
|
||||
),
|
||||
: 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: '메시지'),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '메시지',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: '표시 이름'),
|
||||
decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
|
|
@ -63,12 +63,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID (선택)',
|
||||
helperText: '대화 시작에 필요',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: noteCtrl,
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)'),
|
||||
decoration: const InputDecoration(labelText: '관계 메모 (선택)', border: OutlineInputBorder()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -117,102 +118,54 @@ 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: 160), Center(child: CircularProgressIndicator())])
|
||||
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
children: [
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
if (_contacts.isEmpty)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'),
|
||||
),
|
||||
for (final c in _contacts)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(' · '),
|
||||
),
|
||||
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 SizedBox(height: 72),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: '상대 사용자 ID',
|
||||
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -83,33 +84,14 @@ 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(
|
||||
'분신',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
),
|
||||
title: Text('분신 · ${session.user?.displayName ?? ''}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '사후 알림',
|
||||
|
|
@ -126,123 +108,68 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
|||
},
|
||||
icon: const Icon(Icons.contacts_outlined),
|
||||
),
|
||||
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('자율성 설정'),
|
||||
),
|
||||
),
|
||||
],
|
||||
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),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _createConversation,
|
||||
icon: const Icon(Icons.chat),
|
||||
label: const Text('새 대화'),
|
||||
child: const Icon(Icons.chat),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
? ListView(children: const [SizedBox(height: 120), 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.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
if (_rooms.isEmpty)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
|
||||
),
|
||||
for (final room in _rooms)
|
||||
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),
|
||||
),
|
||||
),
|
||||
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();
|
||||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 72),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,56 +2,11 @@ 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>();
|
||||
|
|
@ -61,49 +16,45 @@ class DataFlowScreen extends StatelessWidget {
|
|||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('데이터 흐름')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.lock_outline,
|
||||
tint: TwinTokens.forest,
|
||||
title: '기기 안에만 둡니다',
|
||||
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
||||
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
||||
Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
||||
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
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 없음 — Chrome·Linux 폴백)',
|
||||
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
|
||||
].join('\n'),
|
||||
),
|
||||
isThreeLine: true,
|
||||
),
|
||||
),
|
||||
_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(
|
||||
'초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n'
|
||||
'채팅 릴레이: 보낸 메시지 본문.\n'
|
||||
'계정: 표시 이름·초대 코드·세션 토큰.\n'
|
||||
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
_section(
|
||||
context,
|
||||
icon: Icons.block,
|
||||
tint: theme.colorScheme.error,
|
||||
title: '보내지 않는 것',
|
||||
body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
|
||||
'관계 메모의 자동 클라우드 분석',
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -51,12 +51,11 @@ class _InboxScreenState extends State<InboxScreen> {
|
|||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: _loading
|
||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
||||
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
||||
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
||||
|
|
@ -65,89 +64,36 @@ class _InboxScreenState extends State<InboxScreen> {
|
|||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
if (_logs.isEmpty)
|
||||
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),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('새 알림이 없습니다.'),
|
||||
),
|
||||
for (final log in _logs)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ 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: capture a few style samples locally.
|
||||
/// Fine copy / import UX waits for human PoC — do not invent §3 defaults here.
|
||||
/// 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.
|
||||
class OnboardingToneScreen extends StatefulWidget {
|
||||
const OnboardingToneScreen({super.key});
|
||||
|
||||
|
|
@ -48,105 +46,46 @@ 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(
|
||||
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('이 말투로 시작'),
|
||||
),
|
||||
),
|
||||
],
|
||||
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(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: () => _save(markDone: true),
|
||||
child: const Text('저장하고 시작'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,53 +72,32 @@ 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: 160), Center(child: CircularProgressIndicator())])
|
||||
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(
|
||||
'이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
for (final s in _sessions)
|
||||
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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
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});
|
||||
|
|
@ -25,189 +21,55 @@ 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: 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: 12),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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.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,
|
||||
),
|
||||
'초대 코드로 클로즈드 베타에 참여합니다.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
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)),
|
||||
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)),
|
||||
],
|
||||
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('시작하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -216,31 +78,3 @@ class _DemoTestPanel extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,12 +60,11 @@ class SessionState extends ChangeNotifier {
|
|||
Future<AppDatabase> _openDb() async {
|
||||
try {
|
||||
final db = await AppDatabase.open();
|
||||
localDbEncrypted = db.encrypted;
|
||||
localDbEncrypted = true;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/models.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Twin messages get a dashed border + badge (PRD §3.1 분신 뱃지) so an
|
||||
/// auto-sent bubble never reads as something the human actually typed.
|
||||
/// Twin messages use a dashed border + badge (PRD §3.1 분신 뱃지).
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
|
|
@ -17,146 +15,90 @@ 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 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 bg = isMine
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest;
|
||||
|
||||
final bubble = Container(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76),
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: radius),
|
||||
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,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (twin)
|
||||
Padding(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'분신',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.tertiary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
_time(message.createdAt),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10),
|
||||
message.retracted ? '(되돌린 메시지)' : message.text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal,
|
||||
color: message.retracted ? theme.disabledColor : null,
|
||||
),
|
||||
),
|
||||
if (twin && isMine && !message.retracted && onRetract != null)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: onRetract,
|
||||
child: const Text('되돌리기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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 Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||
child: Align(
|
||||
// Dashed look for twin: overlay a custom painter border when twin.
|
||||
if (!twin) {
|
||||
return Align(
|
||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: content,
|
||||
child: bubble,
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: CustomPaint(
|
||||
painter: _DashedRRectPainter(color: theme.colorScheme.tertiary),
|
||||
child: bubble,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashedRRectPainter extends CustomPainter {
|
||||
_DashedRRectPainter({required this.color, required this.radius});
|
||||
|
||||
_DashedRRectPainter({required this.color});
|
||||
final Color color;
|
||||
final BorderRadius radius;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.4;
|
||||
final rrect = radius.toRRect(Rect.fromLTWH(0.7, 0.7, size.width - 1.4, size.height - 1.4));
|
||||
..strokeWidth = 1.5;
|
||||
final rrect = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(1, 1, size.width - 2, size.height - 2),
|
||||
const Radius.circular(14),
|
||||
);
|
||||
final path = Path()..addRRect(rrect);
|
||||
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
|
||||
canvas.drawPath(dashed, paint);
|
||||
|
|
@ -177,6 +119,5 @@ class _DashedRRectPainter extends CustomPainter {
|
|||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) =>
|
||||
oldDelegate.color != color || oldDelegate.radius != radius;
|
||||
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Full-bleed Twin Shadow atmosphere: mist→paper gradient + overlapping
|
||||
/// silhouettes (the "second self"). No badges, chips, or promo overlays.
|
||||
class TwinHeroBackdrop extends StatefulWidget {
|
||||
const TwinHeroBackdrop({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -296,14 +296,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ 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:
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 917 B |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -1,41 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue