Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 50 additions & 2 deletions app/lib/backend/http/api/conversations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,9 @@ Future<ServerConversation?> reProcessConversationServer(String conversationId, {
return null;
}

Future<bool> deleteConversationServer(String conversationId) async {
Future<bool> deleteConversationServer(String conversationId, {bool permanent = false}) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId?cascade=true',
url: '${Env.apiBaseUrl}v1/conversations/$conversationId?cascade=true&permanent=$permanent',
headers: {},
method: 'DELETE',
body: '',
Expand All @@ -127,6 +127,54 @@ Future<bool> deleteConversationServer(String conversationId) async {
return response.statusCode == 204;
}

Future<List<ServerConversation>> getTrashedConversationsServer({int limit = 50}) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/trash/conversations?limit=$limit',
headers: {},
method: 'GET',
body: '',
);
if (response == null) return [];
if (response.statusCode == 200) {
final list = jsonDecode(response.body) as List;
return list.map((item) => ServerConversation.fromJson(item)).toList();
}
return [];
}

Future<bool> restoreConversationServer(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/trash/conversations/$conversationId/restore',
headers: {},
method: 'POST',
body: '',
);
if (response == null) return false;
return response.statusCode == 204;
}

Future<bool> permanentlyDeleteTrashedConversationServer(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/trash/conversations/$conversationId',
headers: {},
method: 'DELETE',
body: '',
);
if (response == null) return false;
return response.statusCode == 204;
}

Future<bool> emptyTrashServer({int olderThanDays = 0}) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/trash/conversations?older_than_days=$olderThanDays',
headers: {},
method: 'DELETE',
body: '',
);
if (response == null) return false;
return response.statusCode == 204;
}

Future<bool> unlinkCalendarEvent(String conversationId) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/conversations/$conversationId/calendar-event',
Expand Down
3 changes: 3 additions & 0 deletions app/lib/backend/schema/conversation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ class ServerConversation {

ConversationStatus status;
bool discarded;
DateTime? deletedAt;
final bool deleted;
final bool isLocked;
bool starred;
Expand All @@ -279,6 +280,7 @@ class ServerConversation {
this.photos = const [],
this.audioFiles = const [],
this.discarded = false,
this.deletedAt,
this.deleted = false,
this.source,
this.language,
Expand Down Expand Up @@ -311,6 +313,7 @@ class ServerConversation {
: [],
audioFiles: ((json['audio_files'] ?? []) as List<dynamic>).map((af) => AudioFile.fromJson(af)).toList(),
discarded: json['discarded'] ?? false,
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']).toLocal() : null,
source: json['source'] != null ? ConversationSource.values.asNameMap()[json['source']] : ConversationSource.omi,
language: json['language'],
deleted: json['deleted'] ?? false,
Expand Down
32 changes: 32 additions & 0 deletions app/lib/pages/conversations/conversations_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:omi/widgets/shimmer_with_timeout.dart';
import 'package:visibility_detector/visibility_detector.dart';

import 'package:omi/backend/schema/conversation.dart';
import 'package:omi/pages/capture/widgets/widgets.dart';
import 'package:omi/pages/conversations/trash_page.dart';
import 'package:omi/pages/conversations/widgets/daily_summaries_list.dart';
import 'package:omi/pages/conversations/widgets/folder_tabs.dart';
import 'package:omi/pages/conversations/widgets/goals_widget.dart';
Expand Down Expand Up @@ -332,6 +334,36 @@ class _ConversationsPageState extends State<ConversationsPage> with AutomaticKee
convoProvider.showDailySummaries ? context.l10n.dailyRecaps : context.l10n.conversations,
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
if (!convoProvider.showDailySummaries)
Row(
children: [
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const TrashPage()),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FaIcon(FontAwesomeIcons.trash, color: Colors.grey.shade400, size: 11),
const SizedBox(width: 4),
Text(
'Trash',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
],
),
),
),
],
),
],
),
),
Expand Down
211 changes: 211 additions & 0 deletions app/lib/pages/conversations/trash_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import 'package:font_awesome_flutter/font_awesome_flutter.dart';

import 'package:omi/backend/schema/conversation.dart';
import 'package:omi/providers/conversation_provider.dart';
import 'package:omi/utils/l10n_extensions.dart';

class TrashPage extends StatefulWidget {
const TrashPage({super.key});

@override
State<TrashPage> createState() => _TrashPageState();
}

class _TrashPageState extends State<TrashPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ConversationProvider>().fetchTrashedConversations();
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0D0D0D),
appBar: AppBar(
backgroundColor: const Color(0xFF0D0D0D),
elevation: 0,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.chevronLeft, size: 18),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Trash',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
),
centerTitle: true,
actions: [
Consumer<ConversationProvider>(
builder: (context, provider, child) {
if (provider.trashedConversations.isEmpty) return const SizedBox.shrink();
return TextButton(
onPressed: () => _showEmptyTrashDialog(context, provider),
child: Text(
'Empty Trash',
style: TextStyle(color: Colors.redAccent.shade200, fontWeight: FontWeight.w500),
Comment thread
Syed-Moiz-Ali marked this conversation as resolved.
Outdated
),
);
},
),
],
),
body: Consumer<ConversationProvider>(
builder: (context, provider, child) {
if (provider.isLoadingTrash) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white));
}
if (provider.trashedConversations.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FaIcon(FontAwesomeIcons.trash, color: Colors.grey.shade600, size: 48),
const SizedBox(height: 16),
Text(
'Trash is empty',
style: TextStyle(color: Colors.grey.shade400, fontSize: 18, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Text(
'Deleted conversations appear here',
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: provider.trashedConversations.length,
separatorBuilder: (_, __) => const Divider(height: 1, color: Color(0xFF3C3C43)),
itemBuilder: (context, index) {
final conversation = provider.trashedConversations[index];
return _TrashItem(
conversation: conversation,
onRestore: () => provider.restoreConversationFromTrash(conversation.id),
onPermanentDelete: () => _confirmPermanentDelete(context, provider, conversation),
);
},
);
},
),
);
}

void _confirmPermanentDelete(BuildContext context, ConversationProvider provider, ServerConversation conversation) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1C1C1E),
title: const Text('Delete permanently?', style: TextStyle(color: Colors.white)),
content: const Text(
'This conversation will be permanently deleted and cannot be recovered.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel', style: TextStyle(color: Colors.grey)),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
provider.permanentlyDeleteFromTrash(conversation.id);
},
child: Text('Delete', style: TextStyle(color: Colors.redAccent.shade200)),
),
],
),
);
}

void _showEmptyTrashDialog(BuildContext context, ConversationProvider provider) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1C1C1E),
title: const Text('Empty Trash?', style: TextStyle(color: Colors.white)),
content: const Text(
'All trashed conversations will be permanently deleted.',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel', style: TextStyle(color: Colors.grey)),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
provider.emptyTrash();
},
child: Text('Empty', style: TextStyle(color: Colors.redAccent.shade200)),
),
],
),
);
}
}

class _TrashItem extends StatelessWidget {
final ServerConversation conversation;
final VoidCallback onRestore;
final VoidCallback onPermanentDelete;

const _TrashItem({
required this.conversation,
required this.onRestore,
required this.onPermanentDelete,
});

@override
Widget build(BuildContext context) {
final title = conversation.structured.title ?? 'Untitled';
final deletedAt = conversation.deletedAt;
final deletedDate = deletedAt != null
? '${deletedAt.day}/${deletedAt.month}/${deletedAt.year}'
: 'Recently';

return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Deleted $deletedDate',
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
),
],
),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.rotateLeft, color: Colors.white, size: 14),
tooltip: 'Restore',
onPressed: onRestore,
),
const SizedBox(width: 4),
IconButton(
icon: FaIcon(FontAwesomeIcons.trashCan, color: Colors.redAccent.shade200, size: 14),
tooltip: 'Delete permanently',
onPressed: onPermanentDelete,
),
],
),
);
}
}
Loading