iykyka/mobile/lib/models/models.dart

267 lines
8.8 KiB
Dart

enum SenderMode { human, twin }
// Product autonomy ladder names (PRD) — keep L0/L1/L2 as identifiers.
// ignore: constant_identifier_names
enum AutonomyLevel { L0, L1, L2 }
extension AutonomyLevelLabel on AutonomyLevel {
/// Short label reused verbatim from autonomy_settings_screen.dart's
/// segmented-button labels ("L0"/"L1"/"L2") so per-contact chips
/// (roadmap.md §2.7-D) don't invent new copy.
String get label => name;
}
/// 관계별 페르소나 (roadmap.md §2.7-B, PRD.md §2.1-②/§3.1) — minimum 2 tiers.
enum RelationshipTier {
close,
formal;
static RelationshipTier fromJson(String? raw) => RelationshipTier.values.firstWhere(
(e) => e.name == raw,
orElse: () => RelationshipTier.formal,
);
String get label => this == RelationshipTier.close ? '가까운 사이' : '공식적인 사이';
}
class User {
User({required this.id, required this.displayName, required this.inviteCode});
final int id;
final String displayName;
final String inviteCode;
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as int,
displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? '',
inviteCode: json['invite_code'] as String? ?? json['inviteCode'] as String? ?? '',
);
}
class TwinSettings {
TwinSettings({required this.autonomyLevel, required this.relationshipTier});
final AutonomyLevel autonomyLevel;
final RelationshipTier relationshipTier;
factory TwinSettings.fromJson(Map<String, dynamic> json) {
final raw = (json['autonomy_level'] as String? ?? 'L0').toUpperCase();
return TwinSettings(
autonomyLevel: AutonomyLevel.values.firstWhere(
(e) => e.name == raw,
orElse: () => AutonomyLevel.L0,
),
relationshipTier: RelationshipTier.fromJson(json['relationship_tier'] as String?),
);
}
}
class ConversationSummary {
ConversationSummary({
required this.id,
required this.isGroup,
required this.userIds,
required this.twinDisabledByPeer,
this.twinDisabledByFlood = false,
this.unreadCount = 0,
this.createdAt,
});
final int id;
final bool isGroup;
final List<int> userIds;
final bool twinDisabledByPeer;
// roadmap.md §2.7-C 스팸/도배 감지: 자동으로 켜지지만(사람의 선택이 아님)
// twinDisabledByPeer와 달리 되돌릴 수 있음 (POST /conversations/:id/flood-reset).
final bool twinDisabledByFlood;
final int unreadCount;
final DateTime? createdAt;
factory ConversationSummary.fromJson(Map<String, dynamic> json) {
final rawIds = json['user_ids'] as List<dynamic>? ?? const [];
return ConversationSummary(
id: json['id'] as int,
isGroup: json['is_group'] as bool? ?? false,
userIds: rawIds.map((e) => e as int).toList(),
twinDisabledByPeer: json['twin_disabled_by_peer'] as bool? ?? false,
twinDisabledByFlood: json['twin_disabled_by_flood'] as bool? ?? false,
unreadCount: json['unread_count'] as int? ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? ''),
);
}
/// Title helper when peer display names are not yet loaded.
String titleFor(int myUserId) {
final peers = userIds.where((id) => id != myUserId).toList();
if (isGroup) return '그룹 #$id';
if (peers.isEmpty) return '나와의 대화 #$id';
return '대화 · 상대 #${peers.first}';
}
}
class Contact {
Contact({
required this.id,
required this.displayName,
this.contactUserId,
this.relationshipNote = '',
this.relationshipTier,
this.autonomyLevel,
});
final int id;
final String displayName;
final int? contactUserId;
final String relationshipNote;
/// Per-contact override of the global relationship tier (roadmap.md
/// §2.7-B). Null means "use the global default".
final RelationshipTier? relationshipTier;
/// Per-contact override of the global autonomy level (roadmap.md §2.7-D,
/// PRD.md §3.1 "전역 기본값 + 상대별 예외 설정"). Null means "use the
/// global default".
final AutonomyLevel? autonomyLevel;
factory Contact.fromJson(Map<String, dynamic> json) => Contact(
id: json['id'] as int,
displayName: json['display_name'] as String? ?? '',
contactUserId: json['contact_user_id'] as int?,
relationshipNote: json['relationship_note'] as String? ?? '',
relationshipTier: json['relationship_tier'] == null
? null
: RelationshipTier.fromJson(json['relationship_tier'] as String?),
autonomyLevel: json['autonomy_level'] == null
? null
: AutonomyLevel.values.firstWhere(
(e) => e.name == json['autonomy_level'] as String?,
orElse: () => AutonomyLevel.L0,
),
);
}
class EscalationLogEntry {
EscalationLogEntry({
required this.id,
required this.conversationId,
required this.reason,
required this.messageSnippet,
required this.resolved,
required this.createdAt,
});
final int id;
final int conversationId;
final String reason;
final String messageSnippet;
final bool resolved;
final DateTime createdAt;
factory EscalationLogEntry.fromJson(Map<String, dynamic> json) => EscalationLogEntry(
id: json['id'] as int,
conversationId: json['conversation_id'] as int? ?? 0,
reason: json['reason'] as String? ?? '',
messageSnippet: json['message_snippet'] as String? ?? '',
resolved: json['resolved'] as bool? ?? false,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
);
}
class ChatMessage {
ChatMessage({
required this.id,
required this.conversationId,
required this.senderId,
required this.senderMode,
required this.text,
required this.retracted,
required this.createdAt,
this.draftEdited,
this.naturalnessRating,
});
final int id;
final int conversationId;
final int senderId;
final SenderMode senderMode;
final String text;
final bool retracted;
final DateTime createdAt;
/// L1 approval-rate proxy (PRD.md §5, deploy-checklist.md N4-12) — null
/// for human messages and any twin message the server couldn't diff
/// against an original draft; otherwise true = edited before send,
/// false = sent verbatim. Purely informational on the client; the server
/// is the source of truth (computed once at send time).
final bool? draftEdited;
/// "이 답장 나답아요?" explicit feedback (vision.md metric, N4-12) — null
/// = not rated yet, true = 👍, false = 👎.
final bool? naturalnessRating;
bool get isTwin => senderMode == SenderMode.twin;
factory ChatMessage.fromJson(Map<String, dynamic> json) {
final mode = (json['sender_mode'] as String? ?? 'human').toLowerCase();
return ChatMessage(
id: json['id'] as int,
conversationId: json['conversation_id'] as int? ?? json['conversationId'] as int? ?? 0,
senderId: json['sender_id'] as int? ?? json['senderId'] as int? ?? 0,
senderMode: mode == 'twin' ? SenderMode.twin : SenderMode.human,
text: json['text'] as String? ?? '',
retracted: json['retracted'] as bool? ?? false,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
draftEdited: json['draft_edited'] as bool?,
naturalnessRating: json['naturalness_rating'] as bool?,
);
}
}
class WhitelistRule {
WhitelistRule({required this.id, required this.topicKeyword, this.contactId});
final int id;
final String topicKeyword;
final int? contactId;
factory WhitelistRule.fromJson(Map<String, dynamic> json) => WhitelistRule(
id: json['id'] as int,
topicKeyword: json['topic_keyword'] as String? ?? '',
contactId: json['contact_id'] as int?,
);
}
/// 단톡 따라잡기(roadmap.md §2.7-A) — GET /conversations/:id/summary result.
class GroupSummaryResult {
GroupSummaryResult({
required this.status,
required this.summary,
required this.unreadCount,
required this.needsReply,
});
final String status; // empty | ok
final String summary;
final int unreadCount;
final bool needsReply;
factory GroupSummaryResult.fromJson(Map<String, dynamic> json) => GroupSummaryResult(
status: json['status'] as String? ?? 'empty',
summary: json['summary'] as String? ?? '',
unreadCount: json['unread_count'] as int? ?? 0,
needsReply: json['needs_reply'] as bool? ?? false,
);
bool get isEmpty => status == 'empty' || unreadCount == 0;
}
class DraftResult {
DraftResult({required this.status, required this.text});
final String status; // ok | escalate | no_key
final String text;
factory DraftResult.fromJson(Map<String, dynamic> json) => DraftResult(
status: json['status'] as String? ?? 'ok',
text: json['text'] as String? ?? '',
);
bool get isEscalate => status == 'escalate';
}