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)
|
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
|
||||||
ADMIN_API_TOKEN=
|
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).
|
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
||||||
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
||||||
FCM_SERVER_KEY=
|
FCM_SERVER_KEY=
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,9 @@ When documents disagree, follow this order:
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Q1~Q7 in `decision-log.md` are **확정** (Phase 1 C, 2026-07-30). PoC-dependent
|
- Q1~Q7 in `decision-log.md` are **제안 (tentative)**, not final meeting
|
||||||
sub-questions (default autonomy level, whitelist defaults, final branding)
|
decisions. Do not silently reverse them. If a change is required, update
|
||||||
stay open — do not invent those. To reverse a Q, update `decision-log.md` and
|
`decision-log.md` and all derived docs in the same change.
|
||||||
derived docs in the same change.
|
|
||||||
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
- Prefer `decision-log.md` (and the synced summary in `PLANNING.md` §2) for
|
||||||
current working answers.
|
current working answers.
|
||||||
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
- Working delivery order is **자체 앱 클로즈드 베타 first → OS 레이어 later**
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ Follow the project instructions in [@AGENTS.md](./AGENTS.md).
|
||||||
|
|
||||||
Quick context:
|
Quick context:
|
||||||
|
|
||||||
- Phase 1 A~C are in place (`core-backend/`, `ai-service/`, `mobile/`). Next is
|
- This repo started as planning docs only; Phase 1 app-build has now begun.
|
||||||
**D — human PoC** (`docs/roadmap.md`). Do not start PoC early or invent §3 defaults.
|
Check `docs/roadmap.md` Phase 1 checklist before starting app-build work.
|
||||||
- Working product name: **분신** (가칭 확정).
|
- Working product name: **분신** (tentative).
|
||||||
- Source of decisions: `docs/decision-log.md` (Q1~Q7 **확정**; PoC sub-questions open).
|
- Source of working decisions: `docs/decision-log.md` (status: 제안).
|
||||||
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
|
- v1 scope: self-app closed beta, L0~L2, 읽씹 종결 + 단톡 따라잡기, Android first.
|
||||||
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
|
- Hard bans for v1: L3/L4, OS-layer over third-party messengers, B2B.
|
||||||
- Never weaken escalation, twin badge, peer veto, or undo.
|
- 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"`
|
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 {
|
func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.Use(corsMiddleware())
|
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
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)
|
registerA1A2Routes(r, db)
|
||||||
registerBRoutes(r, db)
|
registerBRoutes(r, db)
|
||||||
registerInviteOpsRoutes(r, db)
|
registerInviteOpsRoutes(r, db)
|
||||||
registerDemoRoutes(r)
|
|
||||||
|
|
||||||
r.GET("/admin/metrics", func(c *gin.Context) {
|
r.GET("/admin/metrics", func(c *gin.Context) {
|
||||||
if !requireAdmin(c) {
|
if !requireAdmin(c) {
|
||||||
|
|
@ -163,25 +138,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
// 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용
|
// 초대 기반 베타(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
|
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 {
|
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||||
return
|
return
|
||||||
|
|
@ -194,21 +151,18 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
c.JSON(status, gin.H{"detail": detail})
|
c.JSON(status, gin.H{"detail": detail})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
user := User{InviteCode: storedInvite, DisplayName: req.DisplayName}
|
user := User{InviteCode: req.InviteCode, DisplayName: req.DisplayName}
|
||||||
if err := db.Create(&user).Error; err != nil {
|
if err := db.Create(&user).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
||||||
|
|
||||||
if !demo {
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
invite.UsedAt = &now
|
invite.UsedAt = &now
|
||||||
invite.UsedByUserID = &user.ID
|
invite.UsedByUserID = &user.ID
|
||||||
db.Save(&invite)
|
db.Save(&invite)
|
||||||
}
|
|
||||||
|
|
||||||
session, err := createSession(db, user.ID)
|
session, err := createSession(db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -696,7 +650,6 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db := openDB()
|
db := openDB()
|
||||||
seedDemoInvite(db)
|
|
||||||
relay := newConnectionManager()
|
relay := newConnectionManager()
|
||||||
ai := newAIServiceClient()
|
ai := newAIServiceClient()
|
||||||
r := setupRouter(db, relay, ai)
|
r := setupRouter(db, relay, ai)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ migration:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||||
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||||
- platform: web
|
- platform: android
|
||||||
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||||
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,6 @@ class AppConfig {
|
||||||
defaultValue: 'http://10.0.2.2:8080', // Android emulator → host localhost
|
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() {
|
static String wsBase() {
|
||||||
final uri = Uri.parse(coreApiBase);
|
final uri = Uri.parse(coreApiBase);
|
||||||
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,128 @@
|
||||||
/// Platform-specific local database entrypoint.
|
import 'dart:ffi';
|
||||||
///
|
import 'dart:io';
|
||||||
/// - IO (Android / iOS / Linux / …): Drift + SQLCipher (`app_database_native.dart`)
|
import 'dart:math';
|
||||||
/// - 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 '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
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
part of 'app_database_native.dart';
|
part of 'app_database.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
class $ToneSamplesTable extends ToneSamples
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
|
@ -8,41 +5,12 @@ import 'screens/conversation_list_screen.dart';
|
||||||
import 'screens/onboarding_tone_screen.dart';
|
import 'screens/onboarding_tone_screen.dart';
|
||||||
import 'screens/signup_screen.dart';
|
import 'screens/signup_screen.dart';
|
||||||
import 'state/session_state.dart';
|
import 'state/session_state.dart';
|
||||||
import 'theme/app_theme.dart';
|
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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();
|
final session = SessionState();
|
||||||
await session.restore().timeout(const Duration(seconds: 8));
|
await session.restore();
|
||||||
runApp(BunsinApp(session: session));
|
runApp(BunsinApp(session: session));
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('BOOT FAIL: $e\n$st');
|
|
||||||
runApp(
|
|
||||||
MaterialApp(
|
|
||||||
theme: AppTheme.light(),
|
|
||||||
home: Scaffold(
|
|
||||||
body: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: SelectableText('앱 시작 실패\n\n$e\n\n$st'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class BunsinApp extends StatelessWidget {
|
class BunsinApp extends StatelessWidget {
|
||||||
|
|
@ -56,10 +24,13 @@ class BunsinApp extends StatelessWidget {
|
||||||
value: session,
|
value: session,
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: '분신',
|
title: '분신',
|
||||||
debugShowCheckedModeBanner: false,
|
theme: ThemeData(
|
||||||
theme: AppTheme.light(),
|
colorScheme: ColorScheme.fromSeed(
|
||||||
// Twin Shadow phase-1: light only (dark kept in AppTheme for later).
|
seedColor: const Color(0xFF1F6F5B),
|
||||||
themeMode: ThemeMode.light,
|
brightness: Brightness.light,
|
||||||
|
),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
home: Consumer<SessionState>(
|
home: Consumer<SessionState>(
|
||||||
builder: (context, s, _) {
|
builder: (context, s, _) {
|
||||||
if (s.user == null) return const SignupScreen();
|
if (s.user == null) return const SignupScreen();
|
||||||
|
|
|
||||||
|
|
@ -49,50 +49,36 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
static const _levelDescriptions = {
|
|
||||||
AutonomyLevel.L0: '분신이 초안만 만들고, 발송은 항상 직접 합니다.',
|
|
||||||
AutonomyLevel.L1: '분신이 초안을 만들면 검토·수정 후 승인해야 보내집니다.',
|
|
||||||
AutonomyLevel.L2: '아래 화이트리스트 주제는 승인 없이 자동으로 보내집니다.',
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('자율성 설정')),
|
appBar: AppBar(title: const Text('자율성 설정')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
|
||||||
Card(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.privacy_tip_outlined),
|
leading: const Icon(Icons.privacy_tip_outlined),
|
||||||
title: const Text('데이터 흐름'),
|
title: const Text('데이터 흐름'),
|
||||||
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
|
subtitle: const Text('무엇이 기기에 남고 서버로 가는지'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DataFlowScreen()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.devices),
|
leading: const Icon(Icons.devices),
|
||||||
title: const Text('로그인 세션'),
|
title: const Text('로그인 세션'),
|
||||||
subtitle: const Text('멀티 디바이스 세션 목록'),
|
subtitle: const Text('멀티 디바이스 세션 목록'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SessionsScreen()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
const Divider(height: 32),
|
||||||
),
|
Text('전역 레벨', style: Theme.of(context).textTheme.titleMedium),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text('전역 자율성 레벨', style: theme.textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SegmentedButton<AutonomyLevel>(
|
SegmentedButton<AutonomyLevel>(
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'),
|
ButtonSegment(value: AutonomyLevel.L0, label: Text('L0'), tooltip: '초안만'),
|
||||||
|
|
@ -102,37 +88,23 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
||||||
selected: {session.autonomyLevel},
|
selected: {session.autonomyLevel},
|
||||||
onSelectionChanged: (s) => session.setAutonomy(s.first),
|
onSelectionChanged: (s) => session.setAutonomy(s.first),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Text(
|
||||||
padding: const EdgeInsets.all(12),
|
'기본값은 L0입니다. L2는 아래 화이트리스트 주제에만 자동 발송됩니다.',
|
||||||
decoration: BoxDecoration(
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
const Divider(height: 32),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text('L2 화이트리스트 주제', style: Theme.of(context).textTheme.titleMedium),
|
||||||
children: [
|
const SizedBox(height: 8),
|
||||||
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: 28),
|
|
||||||
Text('L2 화이트리스트 주제', style: theme.textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _keyword,
|
controller: _keyword,
|
||||||
decoration: const InputDecoration(labelText: '주제 키워드'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: '주제 키워드',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -156,37 +128,24 @@ class _AutonomySettingsScreenState extends State<AutonomySettingsScreen> {
|
||||||
),
|
),
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
const SizedBox(height: 8),
|
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),
|
const SizedBox(height: 12),
|
||||||
if (_loading)
|
if (_loading)
|
||||||
const Padding(
|
const Center(child: CircularProgressIndicator())
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
)
|
|
||||||
else if (_rules.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
child: Text(
|
|
||||||
'아직 화이트리스트 주제가 없습니다.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
Wrap(
|
..._rules.map(
|
||||||
spacing: 8,
|
(r) => ListTile(
|
||||||
runSpacing: 8,
|
title: Text(r.topicKeyword),
|
||||||
children: [
|
trailing: IconButton(
|
||||||
for (final r in _rules)
|
icon: const Icon(Icons.delete_outline),
|
||||||
InputChip(
|
onPressed: () async {
|
||||||
label: Text(r.topicKeyword),
|
|
||||||
onDeleted: () async {
|
|
||||||
if (session.user == null) return;
|
if (session.user == null) return;
|
||||||
await session.api.deleteWhitelist(session.user!.id, r.id);
|
await session.api.deleteWhitelist(session.user!.id, r.id);
|
||||||
setState(() => _rules = _rules.where((x) => x.id != r.id).toList());
|
setState(() => _rules = _rules.where((x) => x.id != r.id).toList());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -205,18 +205,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _veto() async {
|
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>();
|
final session = context.read<SessionState>();
|
||||||
try {
|
try {
|
||||||
await session.api.vetoConversation(widget.conversationId);
|
await session.api.vetoConversation(widget.conversationId);
|
||||||
|
|
@ -241,48 +229,32 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
if (draft == null) return const SizedBox.shrink();
|
if (draft == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
if (draft.isEscalate) {
|
if (draft.isEscalate) {
|
||||||
return Container(
|
return Material(
|
||||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.errorContainer,
|
color: theme.colorScheme.errorContainer,
|
||||||
borderRadius: BorderRadius.circular(16),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text('직접 확인 필요', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)),
|
||||||
children: [
|
|
||||||
Icon(Icons.warning_amber_rounded, size: 18, color: theme.colorScheme.onErrorContainer),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'직접 확인 필요',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text,
|
|
||||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
Text(draft.text.isEmpty ? '민감·확정성 내용으로 분신 발송이 보류되었습니다.' : draft.text),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
|
child: TextButton(onPressed: _rejectDraft, child: const Text('닫기')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Material(
|
||||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
elevation: 2,
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.secondaryContainer,
|
color: theme.colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(16),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -292,19 +264,22 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'L1 승인 — 수정 후 보내기',
|
'L1 승인 — 수정 후 보내기',
|
||||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSecondaryContainer),
|
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _draftEdit,
|
controller: _draftEdit,
|
||||||
minLines: 2,
|
minLines: 2,
|
||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
decoration: const InputDecoration(
|
||||||
decoration: InputDecoration(fillColor: theme.colorScheme.surface),
|
border: OutlineInputBorder(),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white70,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')),
|
TextButton(onPressed: _busy ? null : _rejectDraft, child: const Text('버리기')),
|
||||||
|
|
@ -322,6 +297,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,23 +306,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final me = session.user?.id;
|
final me = session.user?.id;
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
TextButton(onPressed: _busy ? null : _veto, child: const Text('거부권')),
|
||||||
tooltip: '거부권 (분신 자동응대 중단)',
|
|
||||||
onPressed: _busy ? null : _veto,
|
|
||||||
icon: const Icon(Icons.block),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (_banner != null)
|
if (_banner != null)
|
||||||
MaterialBanner(
|
MaterialBanner(
|
||||||
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
content: Text(_banner!),
|
content: Text(_banner!),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
|
TextButton(onPressed: () => setState(() => _banner = null), child: const Text('닫기')),
|
||||||
|
|
@ -355,13 +325,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _loadingHistory
|
child: _loadingHistory
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _messages.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: Text(
|
|
||||||
'아직 메시지가 없습니다. 첫 메시지를 보내 보세요.',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: _scroll,
|
controller: _scroll,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
|
@ -381,14 +344,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _input,
|
controller: _input,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
decoration: const InputDecoration(hintText: '메시지'),
|
decoration: const InputDecoration(
|
||||||
|
hintText: '메시지',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameCtrl,
|
controller: nameCtrl,
|
||||||
decoration: const InputDecoration(labelText: '표시 이름'),
|
decoration: const InputDecoration(labelText: '표시 이름', border: OutlineInputBorder()),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
|
|
@ -63,12 +63,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '상대 사용자 ID (선택)',
|
labelText: '상대 사용자 ID (선택)',
|
||||||
helperText: '대화 시작에 필요',
|
helperText: '대화 시작에 필요',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: noteCtrl,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('연락처')),
|
appBar: AppBar(title: const Text('연락처')),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: _showAddDialog,
|
onPressed: _showAddDialog,
|
||||||
tooltip: '연락처 추가',
|
|
||||||
child: const Icon(Icons.person_add_alt_1),
|
child: const Icon(Icons.person_add_alt_1),
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: _loading
|
child: _loading
|
||||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
children: [
|
children: [
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
),
|
),
|
||||||
if (_contacts.isEmpty)
|
if (_contacts.isEmpty)
|
||||||
Padding(
|
const Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
padding: EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Text('연락처가 없습니다. + 버튼으로 추가하세요.'),
|
||||||
children: [
|
|
||||||
Icon(Icons.person_add_outlined, size: 40, color: theme.colorScheme.outline),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text('연락처가 없습니다', style: theme.textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'오른쪽 아래 버튼으로 첫 연락처를 추가해 보세요.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
for (final c in _contacts)
|
for (final c in _contacts)
|
||||||
Padding(
|
ListTile(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
leading: const CircleAvatar(child: Icon(Icons.person_outline)),
|
||||||
child: ListTile(
|
title: Text(c.displayName),
|
||||||
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(
|
subtitle: Text(
|
||||||
[
|
[
|
||||||
if (c.contactUserId != null) '사용자 #${c.contactUserId}',
|
if (c.contactUserId != null) '사용자 #${c.contactUserId}',
|
||||||
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
if (c.relationshipNote.isNotEmpty) c.relationshipNote,
|
||||||
].join(' · '),
|
].join(' · '),
|
||||||
style: theme.textTheme.bodySmall,
|
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: c.contactUserId == null
|
||||||
mainAxisSize: MainAxisSize.min,
|
? null
|
||||||
children: [
|
: TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
|
||||||
if (c.contactUserId != null)
|
onLongPress: () async {
|
||||||
TextButton(onPressed: () => _startChat(c), child: const Text('대화')),
|
final session = context.read<SessionState>();
|
||||||
IconButton(
|
if (session.user == null) return;
|
||||||
tooltip: '삭제',
|
await session.api.deleteContact(session.user!.id, c.id);
|
||||||
icon: const Icon(Icons.delete_outline, size: 20),
|
setState(() => _contacts = _contacts.where((x) => x.id != c.id).toList());
|
||||||
onPressed: () => _delete(c),
|
},
|
||||||
),
|
),
|
||||||
],
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text('길게 누르면 삭제됩니다.', textAlign: TextAlign.center),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 72),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '상대 사용자 ID',
|
labelText: '상대 사용자 ID',
|
||||||
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
|
helperText: '연락처에 등록된 상대면 연락처 화면에서 시작하는 편이 낫습니다.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final theme = Theme.of(context);
|
|
||||||
final me = session.user?.id;
|
final me = session.user?.id;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text('분신 · ${session.user?.displayName ?? ''}'),
|
||||||
'분신',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '사후 알림',
|
tooltip: '사후 알림',
|
||||||
|
|
@ -126,109 +108,56 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.contacts_outlined),
|
icon: const Icon(Icons.contacts_outlined),
|
||||||
),
|
),
|
||||||
PopupMenuButton<VoidCallback>(
|
IconButton(
|
||||||
tooltip: '더보기',
|
tooltip: '말투 샘플',
|
||||||
icon: const Icon(Icons.more_vert),
|
onPressed: () {
|
||||||
onSelected: (action) => action(),
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const OnboardingToneScreen()));
|
||||||
itemBuilder: (context) => [
|
},
|
||||||
PopupMenuItem(
|
icon: const Icon(Icons.record_voice_over_outlined),
|
||||||
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 AutonomySettingsScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.tune),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
floatingActionButton: FloatingActionButton(
|
||||||
],
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
onPressed: _createConversation,
|
onPressed: _createConversation,
|
||||||
icon: const Icon(Icons.chat),
|
child: const Icon(Icons.chat),
|
||||||
label: const Text('새 대화'),
|
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: _loading
|
child: _loading
|
||||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
children: [
|
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)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
),
|
),
|
||||||
if (_rooms.isEmpty)
|
if (_rooms.isEmpty)
|
||||||
Padding(
|
const Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
padding: EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Text('대화방이 없습니다. + 버튼이나 연락처에서 대화를 시작하세요.'),
|
||||||
children: [
|
|
||||||
Icon(Icons.chat_bubble_outline, size: 40, color: theme.colorScheme.outline),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'대화방이 없습니다',
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'연락처나 "새 대화" 버튼으로 첫 대화를 시작해 보세요.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
for (final room in _rooms)
|
for (final room in _rooms)
|
||||||
Padding(
|
ListTile(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
||||||
child: ListTile(
|
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: _avatarColor(context, room.id),
|
child: Icon(room.isGroup ? Icons.groups_outlined : Icons.chat_bubble_outline),
|
||||||
child: Icon(
|
|
||||||
room.isGroup ? Icons.groups_outlined : Icons.person_outline,
|
|
||||||
color: _onAvatarColor(context, room.id),
|
|
||||||
),
|
),
|
||||||
|
title: Text(me == null ? '대화방 #${room.id}' : room.titleFor(me)),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
'ID ${room.id}',
|
||||||
|
if (room.twinDisabledByPeer) '상대가 분신 거부',
|
||||||
|
].join(' · '),
|
||||||
),
|
),
|
||||||
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 {
|
onTap: () async {
|
||||||
await Navigator.of(context).push(
|
await Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -241,8 +170,6 @@ class _ConversationListScreenState extends State<ConversationListScreen> {
|
||||||
await _load();
|
await _load();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 72),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,56 +2,11 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../state/session_state.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).
|
/// Shows what stays on-device vs what may leave the device (roadmap B / privacy).
|
||||||
class DataFlowScreen extends StatelessWidget {
|
class DataFlowScreen extends StatelessWidget {
|
||||||
const DataFlowScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
|
|
@ -61,49 +16,45 @@ class DataFlowScreen extends StatelessWidget {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('데이터 흐름')),
|
appBar: AppBar(title: const Text('데이터 흐름')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(20),
|
||||||
children: [
|
children: [
|
||||||
_section(
|
Text('기기 안에만 둡니다', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||||
context,
|
const SizedBox(height: 8),
|
||||||
icon: Icons.lock_outline,
|
Text(
|
||||||
tint: TwinTokens.forest,
|
'말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
||||||
title: '기기 안에만 둡니다',
|
|
||||||
body: '말투 샘플·온보딩 입력은 이 기기의 로컬 저장소에만 보관합니다. '
|
|
||||||
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
'원문 대화 전체를 서버로 올리지 않는 것이 기본 원칙입니다.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Card(
|
Card(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.record_voice_over_outlined, color: theme.colorScheme.primary),
|
|
||||||
title: Text('말투 샘플 ${samples.length}개'),
|
title: Text('말투 샘플 ${samples.length}개'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
[
|
[
|
||||||
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
|
||||||
session.localDbEncrypted
|
session.localDbEncrypted ? '저장: drift + SQLCipher(암호화)' : '저장: 메모리 폴백(이 환경에 SQLCipher 없음)',
|
||||||
? '저장: drift + SQLCipher(암호화)'
|
|
||||||
: '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
),
|
),
|
||||||
isThreeLine: true,
|
isThreeLine: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_section(
|
const SizedBox(height: 24),
|
||||||
context,
|
Text('서버로 보낼 수 있는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||||
icon: Icons.cloud_upload_outlined,
|
const SizedBox(height: 8),
|
||||||
tint: TwinTokens.twinMark,
|
Text(
|
||||||
title: '서버로 보낼 수 있는 것',
|
'초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트).\n'
|
||||||
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
|
'채팅 릴레이: 보낸 메시지 본문.\n'
|
||||||
'채팅 릴레이: 보낸 메시지 본문\n'
|
'계정: 표시 이름·초대 코드·세션 토큰.\n'
|
||||||
'계정: 표시 이름·초대 코드·세션 토큰\n'
|
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후.',
|
||||||
'푸시(준비 중): 디바이스 토큰만 등록, 실제 전송은 FCM 프로젝트 연결 후',
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
_section(
|
const SizedBox(height: 24),
|
||||||
context,
|
Text('보내지 않는 것', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||||
icon: Icons.block,
|
const SizedBox(height: 8),
|
||||||
tint: theme.colorScheme.error,
|
Text(
|
||||||
title: '보내지 않는 것',
|
'전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
|
||||||
body: '전체 채팅 백업 업로드, 온디바이스 말투 학습 원문 코퍼스, '
|
'관계 메모의 자동 클라우드 분석.',
|
||||||
'관계 메모의 자동 클라우드 분석',
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,11 @@ class _InboxScreenState extends State<InboxScreen> {
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: _loading
|
child: _loading
|
||||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
'분신이 보류·차단한 내용과 에스컬레이션 기록입니다. '
|
||||||
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
'이미 보낸 분신 메시지는 해당 대화방에서 되돌릴 수 있습니다.',
|
||||||
|
|
@ -65,26 +64,29 @@ class _InboxScreenState extends State<InboxScreen> {
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||||
),
|
),
|
||||||
if (_logs.isEmpty)
|
if (_logs.isEmpty)
|
||||||
Padding(
|
const Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 64, horizontal: 32),
|
padding: EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Text('새 알림이 없습니다.'),
|
||||||
children: [
|
|
||||||
Icon(Icons.notifications_none, size: 40, color: theme.colorScheme.outline),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text('새 알림이 없습니다', style: theme.textTheme.titleMedium),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
for (final log in _logs)
|
for (final log in _logs)
|
||||||
Padding(
|
ListTile(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
leading: Icon(
|
||||||
child: Card(
|
log.resolved ? Icons.check_circle_outline : Icons.warning_amber_rounded,
|
||||||
child: InkWell(
|
color: log.resolved ? theme.colorScheme.primary : theme.colorScheme.error,
|
||||||
borderRadius: BorderRadius.circular(16),
|
),
|
||||||
|
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: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -92,62 +94,6 @@ class _InboxScreenState extends State<InboxScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../state/session_state.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.
|
/// Phase 1 onboarding skeleton: capture a few style samples locally.
|
||||||
/// Fine copy / import UX waits for human PoC — do not invent §3 defaults here.
|
/// Fine copy / import UX waits for human PoC (#1) — do not invent §3 defaults here.
|
||||||
class OnboardingToneScreen extends StatefulWidget {
|
class OnboardingToneScreen extends StatefulWidget {
|
||||||
const OnboardingToneScreen({super.key});
|
const OnboardingToneScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -48,107 +46,48 @@ class _OnboardingToneScreenState extends State<OnboardingToneScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final scheme = theme.colorScheme;
|
|
||||||
final fromMenu = Navigator.of(context).canPop();
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: TwinHeroBackdrop(
|
appBar: AppBar(
|
||||||
child: SafeArea(
|
title: const Text('말투 샘플'),
|
||||||
child: Column(
|
actions: [
|
||||||
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(
|
TextButton(
|
||||||
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
|
onPressed: () => context.read<SessionState>().skipToneOnboarding(),
|
||||||
child: const Text('나중에'),
|
child: const Text('나중에'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
body: SafeArea(
|
||||||
Expanded(
|
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 8, 28, 24),
|
padding: const EdgeInsets.all(24),
|
||||||
children: [
|
children: [
|
||||||
TwinFadeUp(
|
Text('분신이 따라 쓸 말투', style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
|
||||||
child: Text(
|
|
||||||
'말투',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: TwinTokens.forest,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TwinFadeUp(
|
Text(
|
||||||
delay: const Duration(milliseconds: 80),
|
'자주 쓰는 짧은 문장을 3~4개 적어 주세요. 기기에만 저장되며, 초안 요청 시 참고로 씁니다. '
|
||||||
child: Text(
|
'최종 문구·수집 방식은 사람 PoC 이후에 다듬습니다.',
|
||||||
'분신이 따라 쓸 말투',
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: TwinTokens.ink,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
),
|
|
||||||
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++) ...[
|
for (var i = 0; i < _samples.length; i++) ...[
|
||||||
TwinFadeUp(
|
TextField(
|
||||||
delay: Duration(milliseconds: 180 + i * 50),
|
|
||||||
child: TextField(
|
|
||||||
controller: _samples[i],
|
controller: _samples[i],
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '샘플 ${i + 1}',
|
labelText: '샘플 ${i + 1}',
|
||||||
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
|
hintText: i == 0 ? '예: ㅇㅇ 알겠음' : null,
|
||||||
prefixIcon: Padding(
|
border: const OutlineInputBorder(),
|
||||||
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),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
],
|
const SizedBox(height: 8),
|
||||||
),
|
FilledButton(
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(28, 0, 28, 20),
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () => _save(markDone: true),
|
onPressed: () => _save(markDone: true),
|
||||||
child: const Text('이 말투로 시작'),
|
child: const Text('저장하고 시작'),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,55 +72,34 @@ class _SessionsScreenState extends State<SessionsScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('로그인 세션')),
|
appBar: AppBar(title: const Text('로그인 세션')),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: _loading
|
child: _loading
|
||||||
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
|
? ListView(children: const [SizedBox(height: 120), Center(child: CircularProgressIndicator())])
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
padding: EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text('이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.'),
|
||||||
'이 계정에 연결된 활성 세션입니다. 다른 기기를 종료하면 해당 토큰이 즉시 무효화됩니다.',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
),
|
),
|
||||||
for (final s in _sessions)
|
for (final s in _sessions)
|
||||||
Padding(
|
ListTile(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
leading: Icon(s['is_current'] == true ? Icons.smartphone : Icons.devices_other),
|
||||||
child: ListTile(
|
title: Text(s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}'),
|
||||||
leading: CircleAvatar(
|
subtitle: Text('만료: ${s['expires_at'] ?? ''}'),
|
||||||
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(
|
trailing: IconButton(
|
||||||
tooltip: '세션 종료',
|
tooltip: '세션 종료',
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
onPressed: () => _revoke(s),
|
onPressed: () => _revoke(s),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../config.dart';
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../theme/app_theme.dart';
|
|
||||||
import '../widgets/twin_hero_backdrop.dart';
|
|
||||||
|
|
||||||
class SignupScreen extends StatefulWidget {
|
class SignupScreen extends StatefulWidget {
|
||||||
const SignupScreen({super.key});
|
const SignupScreen({super.key});
|
||||||
|
|
@ -25,190 +21,56 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fillDemoCredentials() {
|
|
||||||
_invite.text = AppConfig.demoInviteCode;
|
|
||||||
_name.text = AppConfig.demoDisplayName;
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
final theme = Theme.of(context);
|
|
||||||
final scheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: TwinHeroBackdrop(
|
body: SafeArea(
|
||||||
child: SafeArea(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Spacer(flex: 2),
|
const SizedBox(height: 48),
|
||||||
TwinFadeUp(
|
Text('분신', style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w800)),
|
||||||
child: Text(
|
const SizedBox(height: 8),
|
||||||
'분신',
|
Text(
|
||||||
textAlign: TextAlign.center,
|
'초대 코드로 클로즈드 베타에 참여합니다.',
|
||||||
style: theme.textTheme.displayLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
fontSize: 56,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: TwinTokens.ink,
|
|
||||||
letterSpacing: -1.6,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 32),
|
||||||
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(
|
TextField(
|
||||||
controller: _invite,
|
controller: _invite,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '초대 코드',
|
labelText: '초대 코드',
|
||||||
prefixIcon: Icon(Icons.vpn_key_outlined),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
textCapitalization: TextCapitalization.characters,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _name,
|
controller: _name,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: '표시 이름',
|
labelText: '표시 이름',
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) {
|
|
||||||
if (!session.loading) {
|
|
||||||
session.signup(_invite.text.trim(), _name.text.trim());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (session.error != null) ...[
|
if (session.error != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(session.error!, style: TextStyle(color: scheme.error)),
|
Text(session.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const Spacer(),
|
||||||
_PressScale(
|
FilledButton(
|
||||||
child: FilledButton(
|
|
||||||
onPressed: session.loading
|
onPressed: session.loading
|
||||||
? null
|
? null
|
||||||
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
: () => session.signup(_invite.text.trim(), _name.text.trim()),
|
||||||
child: session.loading
|
child: session.loading
|
||||||
? const SizedBox(
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
height: 22,
|
|
||||||
width: 22,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
||||||
)
|
|
||||||
: const Text('시작하기'),
|
: 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),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'테스트용 (누구나)',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: TwinTokens.forest,
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
color: TwinTokens.ink,
|
|
||||||
height: 1.45,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: '코드 복사',
|
|
||||||
onPressed: onCopy,
|
|
||||||
icon: const Icon(Icons.copy_rounded, size: 20),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: TwinTokens.ink.withValues(alpha: 0.55)),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -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 {
|
Future<AppDatabase> _openDb() async {
|
||||||
try {
|
try {
|
||||||
final db = await AppDatabase.open();
|
final db = await AppDatabase.open();
|
||||||
localDbEncrypted = db.encrypted;
|
localDbEncrypted = true;
|
||||||
return db;
|
return db;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
// Linux CI / hosts without libsqlcipher.so — fall back to memory so the
|
||||||
// app still boots; Android release path uses SQLCipher.
|
// 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');
|
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
|
||||||
localDbEncrypted = false;
|
localDbEncrypted = false;
|
||||||
return AppDatabase.memory();
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/models.dart';
|
import '../models/models.dart';
|
||||||
import '../theme/app_theme.dart';
|
|
||||||
|
|
||||||
/// Twin messages get a dashed border + badge (PRD §3.1 분신 뱃지) so an
|
/// Twin messages use a dashed border + badge (PRD §3.1 분신 뱃지).
|
||||||
/// auto-sent bubble never reads as something the human actually typed.
|
|
||||||
class MessageBubble extends StatelessWidget {
|
class MessageBubble extends StatelessWidget {
|
||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -17,146 +15,90 @@ class MessageBubble extends StatelessWidget {
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
final VoidCallback? onRetract;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final scheme = theme.colorScheme;
|
|
||||||
final twin = message.isTwin;
|
final twin = message.isTwin;
|
||||||
final retracted = message.retracted;
|
final bg = isMine
|
||||||
final accent = AppTheme.twinAccent(theme.brightness);
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.colorScheme.surfaceContainerHighest;
|
||||||
final Color bg;
|
|
||||||
final Color fg;
|
|
||||||
if (retracted) {
|
|
||||||
bg = scheme.surfaceContainerHigh;
|
|
||||||
fg = scheme.onSurfaceVariant;
|
|
||||||
} else if (isMine) {
|
|
||||||
bg = scheme.primaryContainer;
|
|
||||||
fg = scheme.onPrimaryContainer;
|
|
||||||
} else {
|
|
||||||
bg = scheme.surfaceContainerHighest;
|
|
||||||
fg = scheme.onSurface;
|
|
||||||
}
|
|
||||||
|
|
||||||
final radius = BorderRadius.only(
|
|
||||||
topLeft: const Radius.circular(18),
|
|
||||||
topRight: const Radius.circular(18),
|
|
||||||
bottomLeft: Radius.circular(isMine ? 18 : 4),
|
|
||||||
bottomRight: Radius.circular(isMine ? 4 : 18),
|
|
||||||
);
|
|
||||||
|
|
||||||
final bubble = Container(
|
final bubble = Container(
|
||||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.76),
|
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.78),
|
||||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||||
decoration: BoxDecoration(color: bg, borderRadius: radius),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
if (twin)
|
if (twin)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Row(
|
child: Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.auto_awesome, size: 13, color: accent),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'분신',
|
'분신',
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: accent,
|
color: theme.colorScheme.tertiary,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.2,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (retracted)
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.replay, size: 14, color: fg),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
Text(
|
||||||
'되돌린 메시지',
|
message.retracted ? '(되돌린 메시지)' : message.text,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: fg, fontStyle: FontStyle.italic),
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontStyle: message.retracted ? FontStyle.italic : FontStyle.normal,
|
||||||
|
color: message.retracted ? theme.disabledColor : null,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Text(
|
|
||||||
message.text,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: fg, height: 1.35),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
if (twin && isMine && !message.retracted && onRetract != null)
|
||||||
Text(
|
Align(
|
||||||
_time(message.createdAt),
|
alignment: Alignment.centerRight,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(color: fg.withOpacity(0.55), fontSize: 10),
|
child: TextButton(
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
onPressed: onRetract,
|
||||||
style: TextButton.styleFrom(
|
child: const Text('되돌리기'),
|
||||||
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(
|
// Dashed look for twin: overlay a custom painter border when twin.
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
if (!twin) {
|
||||||
child: Align(
|
return Align(
|
||||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
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 {
|
class _DashedRRectPainter extends CustomPainter {
|
||||||
_DashedRRectPainter({required this.color, required this.radius});
|
_DashedRRectPainter({required this.color});
|
||||||
|
|
||||||
final Color color;
|
final Color color;
|
||||||
final BorderRadius radius;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..color = color
|
..color = color
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1.4;
|
..strokeWidth = 1.5;
|
||||||
final rrect = radius.toRRect(Rect.fromLTWH(0.7, 0.7, size.width - 1.4, size.height - 1.4));
|
final rrect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTWH(1, 1, size.width - 2, size.height - 2),
|
||||||
|
const Radius.circular(14),
|
||||||
|
);
|
||||||
final path = Path()..addRRect(rrect);
|
final path = Path()..addRRect(rrect);
|
||||||
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
|
final dashed = _dashPath(path, dashLength: 5, gapLength: 4);
|
||||||
canvas.drawPath(dashed, paint);
|
canvas.drawPath(dashed, paint);
|
||||||
|
|
@ -177,6 +119,5 @@ class _DashedRRectPainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) =>
|
bool shouldRepaint(covariant _DashedRRectPainter oldDelegate) => oldDelegate.color != color;
|
||||||
oldDelegate.color != color || oldDelegate.radius != radius;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
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:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ dependencies:
|
||||||
sqlite3: ^2.9.4
|
sqlite3: ^2.9.4
|
||||||
flutter_secure_storage: ^10.3.1
|
flutter_secure_storage: ^10.3.1
|
||||||
sqlcipher_flutter_libs: ^0.6.8
|
sqlcipher_flutter_libs: ^0.6.8
|
||||||
google_fonts: ^6.3.2
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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