Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions app/lib/backend/http/api/conversations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ Future<List<ServerConversation>> getConversations({
if (response.statusCode == 200) {
// decode body bytes to utf8 string and then parse json so as to avoid utf8 char issues
var body = utf8.decode(response.bodyBytes);
var memories = (jsonDecode(body) as List<dynamic>)
.map((conversation) => ServerConversation.fromJson(conversation))
.toList();
var memories =
(jsonDecode(body) as List<dynamic>).map((conversation) => ServerConversation.fromJson(conversation)).toList();
Logger.debug('getConversations length: ${memories.length}');
return memories;
} else {
Expand Down Expand Up @@ -99,6 +98,54 @@ Future<bool> deleteConversationServer(String conversationId) async {
return response.statusCode == 204;
}

Future<ServerConversation?> trashConversationServer(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId/trash',
headers: {},
method: 'POST',
body: '',
);
if (response == null) return null;
Logger.debug('trashConversation: ${response.statusCode}');
if (response.statusCode == 200) {
return ServerConversation.fromJson(jsonDecode(response.body));
}
return null;
}

Future<ServerConversation?> restoreConversationServer(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId/restore',
headers: {},
method: 'POST',
body: '',
);
if (response == null) return null;
Logger.debug('restoreConversation: ${response.statusCode}');
if (response.statusCode == 200) {
return ServerConversation.fromJson(jsonDecode(response.body));
}
return null;
}

Future<List<ServerConversation>> getTrashedConversations({int limit = 100, int offset = 0}) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/trash?limit=$limit&offset=$offset',
headers: {},
method: 'GET',
body: '',
);
if (response == null) return [];
if (response.statusCode == 200) {
var body = utf8.decode(response.bodyBytes);
return (jsonDecode(body) as List<dynamic>)
.map((conversation) => ServerConversation.fromJson(conversation))
.toList();
}
Logger.debug('getTrashedConversations error ${response.statusCode}');
return [];
}

Future<ServerConversation?> getConversationById(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId',
Expand Down Expand Up @@ -168,9 +215,8 @@ class TranscriptsResponse {
deepgram: (json['deepgram'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
soniox: (json['soniox'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
whisperx: (json['whisperx'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
speechmatics: (json['speechmatics'] as List<dynamic>)
.map((segment) => TranscriptSegment.fromJson(segment))
.toList(),
speechmatics:
(json['speechmatics'] as List<dynamic>).map((segment) => TranscriptSegment.fromJson(segment)).toList(),
);
}
}
Expand Down
53 changes: 26 additions & 27 deletions app/lib/backend/schema/conversation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,9 @@ class ConversationPostProcessing {

factory ConversationPostProcessing.fromJson(Map<String, dynamic> json) {
return ConversationPostProcessing(
status:
ConversationPostProcessingStatus.values.asNameMap()[json['status']] ??
status: ConversationPostProcessingStatus.values.asNameMap()[json['status']] ??
ConversationPostProcessingStatus.in_progress,
model:
ConversationPostProcessingModel.values.asNameMap()[json['model']] ??
model: ConversationPostProcessingModel.values.asNameMap()[json['model']] ??
ConversationPostProcessingModel.fal_whisperx,
failReason: json['fail_reason'],
);
Expand Down Expand Up @@ -142,12 +140,12 @@ class ConversationPhoto {
}

Map<String, dynamic> toJson() => {
'id': id,
'base64': base64,
'description': description,
'created_at': createdAt.toUtc().toIso8601String(),
'discarded': discarded,
};
'id': id,
'base64': base64,
'description': description,
'created_at': createdAt.toUtc().toIso8601String(),
'discarded': discarded,
};
}

class AudioFile {
Expand Down Expand Up @@ -182,14 +180,14 @@ class AudioFile {
}

Map<String, dynamic> toJson() => {
'id': id,
'uid': uid,
'conversation_id': conversationId,
'chunk_timestamps': chunkTimestamps,
'provider': provider,
'started_at': startedAt?.toUtc().toIso8601String(),
'duration': duration,
};
'id': id,
'uid': uid,
'conversation_id': conversationId,
'chunk_timestamps': chunkTimestamps,
'provider': provider,
'started_at': startedAt?.toUtc().toIso8601String(),
'duration': duration,
};
}

class ServerConversation {
Expand All @@ -213,6 +211,7 @@ class ServerConversation {

ConversationStatus status;
bool discarded;
DateTime? trashedAt;
final bool deleted;
final bool isLocked;
bool starred;
Expand All @@ -235,6 +234,7 @@ class ServerConversation {
this.photos = const [],
this.audioFiles = const [],
this.discarded = false,
this.trashedAt,
this.deleted = false,
this.source,
this.language,
Expand All @@ -256,24 +256,22 @@ class ServerConversation {
transcriptSegments: ((json['transcript_segments'] ?? []) as List<dynamic>)
.map((segment) => TranscriptSegment.fromJson(segment))
.toList(),
appResults: ((json['apps_results'] ?? []) as List<dynamic>)
.map((result) => AppResponse.fromJson(result))
.toList(),
suggestedSummarizationApps: ((json['suggested_summarization_apps'] ?? []) as List<dynamic>)
.map((appId) => appId.toString())
.toList(),
appResults:
((json['apps_results'] ?? []) as List<dynamic>).map((result) => AppResponse.fromJson(result)).toList(),
suggestedSummarizationApps:
((json['suggested_summarization_apps'] ?? []) as List<dynamic>).map((appId) => appId.toString()).toList(),
geolocation: json['geolocation'] != null ? Geolocation.fromJson(json['geolocation']) : null,
photos: json['photos'] != null
? ((json['photos'] ?? []) as List<dynamic>).map((photo) => ConversationPhoto.fromJson(photo)).toList()
: [],
audioFiles: ((json['audio_files'] ?? []) as List<dynamic>).map((af) => AudioFile.fromJson(af)).toList(),
discarded: json['discarded'] ?? false,
trashedAt: json['trashed_at'] != null ? DateTime.parse(json['trashed_at']).toLocal() : null,
source: json['source'] != null ? ConversationSource.values.asNameMap()[json['source']] : ConversationSource.omi,
language: json['language'],
deleted: json['deleted'] ?? false,
externalIntegration: json['external_data'] != null
? ConversationExternalData.fromJson(json['external_data'])
: null,
externalIntegration:
json['external_data'] != null ? ConversationExternalData.fromJson(json['external_data']) : null,
status: json['status'] != null
? ConversationStatus.values.asNameMap()[json['status']] ?? ConversationStatus.completed
: ConversationStatus.completed,
Expand All @@ -297,6 +295,7 @@ class ServerConversation {
'geolocation': geolocation?.toJson(),
'photos': photos.map((photo) => photo.toJson()).toList(),
'discarded': discarded,
'trashed_at': trashedAt?.toUtc().toIso8601String(),
'deleted': deleted,
'source': source?.toString(),
'language': language,
Expand Down
53 changes: 53 additions & 0 deletions app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,59 @@
"@deleteConversation": {
"description": "Menu item to delete a conversation"
},
"trash": "Trash",
"@trash": {
"description": "Settings entry and page title for trashed conversations"
},
"trashEmpty": "Trash is empty",
"@trashEmpty": {
"description": "Empty state title for Trash"
},
"trashDescription": "Conversations in Trash are permanently deleted after 30 days.",
"@trashDescription": {
"description": "Description of Trash retention behavior"
},
"moveToTrash": "Move to Trash",
"@moveToTrash": {
"description": "Action label to soft-delete a conversation"
},
"restoreConversation": "Restore",
"@restoreConversation": {
"description": "Action label to restore a trashed conversation"
},
"deleteForever": "Delete forever",
"@deleteForever": {
"description": "Action label to permanently delete a trashed conversation"
},
"daysRemaining": "{days} days remaining",
"@daysRemaining": {
"description": "Number of days remaining before a trashed conversation is permanently deleted",
"placeholders": {
"days": {
"type": "int"
}
}
},
"trashConfirmTitle": "Move conversation to Trash?",
"@trashConfirmTitle": {
"description": "Confirmation dialog title for moving a conversation to Trash"
},
"trashConfirmMessage": "You can restore it from Settings > Trash for the next 30 days.",
"@trashConfirmMessage": {
"description": "Confirmation dialog message for moving a conversation to Trash"
},
"restoreSuccess": "Conversation restored",
"@restoreSuccess": {
"description": "Snackbar message after restoring a conversation"
},
"deleteForeverConfirmTitle": "Delete forever?",
"@deleteForeverConfirmTitle": {
"description": "Confirmation dialog title for permanent delete"
},
"trashedAtLabel": "Moved to Trash",
"@trashedAtLabel": {
"description": "Label for the timestamp when a conversation was moved to Trash"
},
"contentCopied": "Content copied to clipboard",
"@contentCopied": {
"description": "Snackbar message when content is copied"
Expand Down
46 changes: 46 additions & 0 deletions app/lib/pages/conversations/widgets/conversation_list_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ class _ConversationListItemState extends State<ConversationListItem> {
padding: EdgeInsets.only(right: 4.0),
child: FaIcon(FontAwesomeIcons.solidStar, size: 12, color: Colors.amber),
),
_buildOverflowMenu(context),
],
)
: Row(
Expand Down Expand Up @@ -408,6 +409,7 @@ class _ConversationListItemState extends State<ConversationListItem> {
padding: EdgeInsets.only(right: 4.0),
child: FaIcon(FontAwesomeIcons.solidStar, size: 12, color: Colors.amber),
),
_buildOverflowMenu(context),
],
),
],
Expand Down Expand Up @@ -576,6 +578,7 @@ class _ConversationListItemState extends State<ConversationListItem> {
padding: EdgeInsets.only(left: 8.0),
child: FaIcon(FontAwesomeIcons.solidStar, size: 12, color: Colors.amber),
),
_buildOverflowMenu(context),
],
),
),
Expand All @@ -590,6 +593,49 @@ class _ConversationListItemState extends State<ConversationListItem> {

return secondsToCompactDuration(durationSeconds, context);
}

Widget _buildOverflowMenu(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_horiz, color: Color(0xFF8E8E93), size: 20),
color: const Color(0xFF1F1F25),
onSelected: (value) async {
if (value == 'trash') {
await _confirmMoveToTrash(context);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'trash',
child: Row(
children: [
const Icon(Icons.delete_outline, color: Colors.white70, size: 18),
const SizedBox(width: 10),
Text(context.l10n.moveToTrash, style: const TextStyle(color: Colors.white)),
],
),
),
],
);
}

Future<void> _confirmMoveToTrash(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.trashConfirmTitle),
content: Text(context.l10n.trashConfirmMessage),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(context.l10n.cancel)),
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: Text(context.l10n.moveToTrash)),
],
),
);
if (confirmed != true || !context.mounted) return;

final success = await context.read<ConversationProvider>().trashConversation(widget.conversation);
if (!context.mounted || !success) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.trashedAtLabel)));
}
}

class ConversationNewStatusIndicator extends StatefulWidget {
Expand Down
Loading
Loading