diff --git a/app/lib/backend/http/api/conversations.dart b/app/lib/backend/http/api/conversations.dart index baf396826..70ccc26ea 100644 --- a/app/lib/backend/http/api/conversations.dart +++ b/app/lib/backend/http/api/conversations.dart @@ -380,3 +380,27 @@ Future<(List, int, int)> searchConversationsServer( } return ([], 0, 0); } + +Future<(List, int, int)> searchConversationsByDateServer( + DateTime date, { + int? page, + int? limit, + bool includeDiscarded = true, +}) async { + + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/memories/date_search', // Call the new endpoint + headers: {}, + method: 'POST', + body: jsonEncode({'date': date,'page': page ?? 1, 'per_page': limit ?? 10, 'include_discarded': includeDiscarded,}), + ); + if (response == null) return ([], 0, 0); + if (response.statusCode == 200) { + List items = (jsonDecode(response.body))['items']; + int currentPage = (jsonDecode(response.body))['current_page']; + int totalPages = (jsonDecode(response.body))['total_pages']; + var convos = items.map((item) => ServerConversation.fromJson(item)).toList(); + return (convos, currentPage, totalPages); + } + return ([], 0, 0); +} diff --git a/app/lib/pages/conversations/widgets/calendar.dart b/app/lib/pages/conversations/widgets/calendar.dart new file mode 100644 index 000000000..7e759ed69 --- /dev/null +++ b/app/lib/pages/conversations/widgets/calendar.dart @@ -0,0 +1,104 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:friend_private/providers/conversation_provider.dart'; + +class CalendarIconButton extends StatelessWidget { + const CalendarIconButton({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, convoProvider, child) { + return Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + child: IconButton( + onPressed: () async { + if (Platform.isAndroid) { + // Android: Show Material Design DatePicker + DateTime? picked = await showDatePicker( + context: context, + initialDate: convoProvider.selectedDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now(), + builder: (context, child) { + return Dialog( + backgroundColor: Colors.transparent, + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.75, + child: Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: Colors.white, + onPrimary: Colors.grey.shade900, + surface: Colors.grey.shade900, + onSurface: Colors.white, + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + ), + ), + ), + child: child!, + ), + ), + ); + }, + ); + + if (picked != null && picked != convoProvider.selectedDate) { + convoProvider.selectDate(picked); + } + } else if (Platform.isIOS) { + // iOS: Show Cupertino-style DatePicker + showCupertinoModalPopup( + context: context, + builder: (context) { + DateTime selectedDate = + convoProvider.selectedDate ?? DateTime.now(); + return Container( + height: 250, + color: Colors.white, + child: Column( + children: [ + SizedBox( + height: 200, + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: selectedDate, + minimumDate: DateTime(2000), + maximumDate: DateTime.now(), + onDateTimeChanged: (DateTime newDate) { + convoProvider.selectDate(newDate); + }, + ), + ), + CupertinoButton( + child: const Text('Done'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + }, + ); + } + }, + icon: const Icon( + Icons.calendar_today, + color: Colors.white, + size: 20, + ), + ), + ); + }, + ); + } +} diff --git a/app/lib/pages/conversations/widgets/search_widget.dart b/app/lib/pages/conversations/widgets/search_widget.dart index a584683b6..c1bac381c 100644 --- a/app/lib/pages/conversations/widgets/search_widget.dart +++ b/app/lib/pages/conversations/widgets/search_widget.dart @@ -3,6 +3,7 @@ import 'package:friend_private/providers/conversation_provider.dart'; import 'package:friend_private/providers/home_provider.dart'; import 'package:friend_private/utils/other/debouncer.dart'; import 'package:provider/provider.dart'; +import 'calendar.dart'; // Relative path import class SearchWidget extends StatefulWidget { const SearchWidget({super.key}); @@ -82,6 +83,10 @@ class _SearchWidgetState extends State { const SizedBox( width: 12, ), + const CalendarIconButton(), + const SizedBox( + width: 12, + ), Consumer( builder: (BuildContext context, ConversationProvider convoProvider, Widget? child) { return Container( diff --git a/app/lib/providers/conversation_provider.dart b/app/lib/providers/conversation_provider.dart index a9bae28e5..7193a58e9 100644 --- a/app/lib/providers/conversation_provider.dart +++ b/app/lib/providers/conversation_provider.dart @@ -93,6 +93,39 @@ class ConversationProvider extends ChangeNotifier implements IWalServiceListener notifyListeners(); } + DateTime? selectedDate; + List dateFilteredConversations = []; + int currentDateSearchPage = 1; + int totalDateSearchPages = 1; + + void selectDate(DateTime date) async { + selectedDate = date; + currentDateSearchPage = 0; + await _fetchConversationsByDate(); + notifyListeners(); + } + + Future _fetchConversationsByDate() async { + if (selectedDate == null) { + dateFilteredConversations = []; + return; + } + + setIsFetchingConversations(true); + var (convos, current, total) = await searchConversationsByDateServer( + selectedDate!, + includeDiscarded: showDiscardedConversations, + page: currentDateSearchPage, + ); + + dateFilteredConversations = convos; + currentDateSearchPage = current; + totalDateSearchPages = total; + groupSearchConvosByDate(); + setIsFetchingConversations(false); + notifyListeners(); + } + Future searchMoreConversations() async { if (totalSearchPages < currentSearchPage + 1) { return; @@ -581,4 +614,5 @@ class ConversationProvider extends ChangeNotifier implements IWalServiceListener isFetchingConversations = value; notifyListeners(); } + } diff --git a/backend/models/memory.py b/backend/models/memory.py index 82ba61ce5..a2033b8d1 100644 --- a/backend/models/memory.py +++ b/backend/models/memory.py @@ -286,3 +286,9 @@ class SearchRequest(BaseModel): page: Optional[int] = 1 per_page: Optional[int] = 10 include_discarded: Optional[bool] = True + +class DateRequest(BaseModel): + date: datetime + page: Optional[int] = 1 + per_page: Optional[int] = 10 + include_discarded: Optional[bool] = True diff --git a/backend/routers/memories.py b/backend/routers/memories.py index a0e663f26..b0b045b78 100644 --- a/backend/routers/memories.py +++ b/backend/routers/memories.py @@ -8,6 +8,7 @@ from routers.transcribe_v2 import retrieve_in_progress_memory from utils.memories.process_memory import process_memory from utils.memories.search import search_memories +from utils.memories.calendar import filter_memories_by_date from utils.other import endpoints as auth from utils.other.storage import get_memory_recording_if_exists, \ delete_additional_profile_audio, delete_speech_sample_for_people @@ -344,3 +345,9 @@ def get_public_memories(offset: int = 0, limit: int = 1000): def search_memories_endpoint(search_request: SearchRequest, uid: str = Depends(auth.get_current_user_uid)): return search_memories(query=search_request.query, page=search_request.page, per_page=search_request.per_page, uid=uid, include_discarded=search_request.include_discarded) + +@router.post("/v1/memories/date_search", response_model=dict, tags=['memories']) # New endpoint for date search +def filter_memories_by_date_endpoint(date_request: DateRequest,uid: str = Depends(auth.get_current_user_uid)): + return filter_memories_by_date(date=date_request.date, page=date_request.page, + per_page=date_request.per_page, uid=uid, + include_discarded=date_request.include_discarded) diff --git a/backend/utils/memories/calendar.py b/backend/utils/memories/calendar.py new file mode 100644 index 000000000..66f6a4bc7 --- /dev/null +++ b/backend/utils/memories/calendar.py @@ -0,0 +1,54 @@ +import math +import os +from datetime import datetime +from typing import Dict + +import typesense + +client = typesense.Client({ + 'nodes': [{ + 'host': os.getenv('TYPESENSE_HOST'), + 'port': os.getenv('TYPESENSE_HOST_PORT'), + 'protocol': 'https' + }], + 'api_key': os.getenv('TYPESENSE_API_KEY'), + 'connection_timeout_seconds': 2 +}) + + +def filter_memories_by_date( + uid: str, + date: str, + page: int = 1, + per_page: int = 10, + include_discarded: bool = True, +) -> Dict: + try: + filter_by = f'userId:={uid} && deleted:=false' + if not include_discarded: + filter_by += ' && discarded:=false' + + filter_by += f' && created_at:={date}' + + search_parameters = { + 'filter_by': filter_by, # Only filter by date + 'sort_by': 'created_at:desc', + 'per_page': per_page, + 'page': page, + } + + results = client.collections['memories'].documents.search(search_parameters) + memories = [] + for item in results['hits']: + item['document']['created_at'] = datetime.utcfromtimestamp(item['document']['created_at']).isoformat() + item['document']['started_at'] = datetime.utcfromtimestamp(item['document']['started_at']).isoformat() + item['document']['finished_at'] = datetime.utcfromtimestamp(item['document']['finished_at']).isoformat() + memories.append(item['document']) + return { + 'items': memories, + 'total_pages': math.ceil(results['found'] / per_page), + 'current_page': page, + 'per_page': per_page + } + except Exception as e: + raise Exception(f"Failed to search conversations: {str(e)}")