From df2a921ffd52a83eb1a38b8f1a55632146f2a499 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 6 Dec 2025 19:44:34 +0530 Subject: [PATCH 1/2] feat(notes): Add notes feature with editor page and drawer integration --- lib/core/models/note.dart | 214 ++++ lib/core/providers/app_providers.dart | 16 + lib/core/router/app_router.dart | 18 + lib/core/services/api_service.dart | 269 ++++- lib/core/services/navigation_service.dart | 4 + .../navigation/widgets/chats_drawer.dart | 19 + .../notes/providers/notes_providers.dart | 297 +++++ .../notes/views/note_editor_page.dart | 1042 +++++++++++++++++ lib/features/notes/views/notes_list_page.dart | 858 ++++++++++++++ lib/l10n/app_en.arb | 200 ++++ 10 files changed, 2888 insertions(+), 49 deletions(-) create mode 100644 lib/core/models/note.dart create mode 100644 lib/features/notes/providers/notes_providers.dart create mode 100644 lib/features/notes/views/note_editor_page.dart create mode 100644 lib/features/notes/views/notes_list_page.dart diff --git a/lib/core/models/note.dart b/lib/core/models/note.dart new file mode 100644 index 0000000..7604e27 --- /dev/null +++ b/lib/core/models/note.dart @@ -0,0 +1,214 @@ +// ignore_for_file: invalid_annotation_target + +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'note.freezed.dart'; +part 'note.g.dart'; + +/// Content structure for a note, supporting multiple formats. +@freezed +sealed class NoteContent with _$NoteContent { + const factory NoteContent({ + /// Raw JSON content from rich text editor (if any) + Object? json, + + /// HTML representation + @Default('') String html, + + /// Markdown representation + @Default('') String md, + }) = _NoteContent; + + factory NoteContent.fromJson(Map json) => + _$NoteContentFromJson(json); +} + +/// Data payload for a note, containing content and optional files/versions. +@freezed +sealed class NoteData with _$NoteData { + const factory NoteData({ + /// The main content of the note + @Default(NoteContent()) NoteContent content, + + /// Previous versions for undo/history + @Default([]) List versions, + + /// Attached files (if any) + @_FileListConverter() List>? files, + }) = _NoteData; + + factory NoteData.fromJson(Map json) => + _$NoteDataFromJson(json); +} + +/// Converter for files list which can be null or a list of maps. +class _FileListConverter + implements JsonConverter>?, Object?> { + const _FileListConverter(); + + @override + List>? fromJson(Object? json) { + if (json == null) return null; + if (json is List) { + return json.whereType>().toList(); + } + return null; + } + + @override + Object? toJson(List>? object) => object; +} + +/// User information associated with a note. +@freezed +sealed class NoteUser with _$NoteUser { + const factory NoteUser({ + required String id, + String? name, + String? email, + @JsonKey(name: 'profile_image_url') String? profileImageUrl, + }) = _NoteUser; + + factory NoteUser.fromJson(Map json) => + _$NoteUserFromJson(json); +} + +/// A Note model matching the OpenWebUI notes API structure. +@freezed +sealed class Note with _$Note { + const Note._(); + + const factory Note({ + required String id, + @JsonKey(name: 'user_id') required String userId, + required String title, + + /// Note content and associated data + @_NoteDataConverter() @Default(NoteData()) NoteData data, + + /// Additional metadata + @_MetadataConverter() Map? meta, + + /// Access control settings + @JsonKey(name: 'access_control') + @_MetadataConverter() + Map? accessControl, + + /// Creation timestamp in nanoseconds + @JsonKey(name: 'created_at') required int createdAt, + + /// Last update timestamp in nanoseconds + @JsonKey(name: 'updated_at') required int updatedAt, + + /// User who created the note (optional, from extended response) + NoteUser? user, + }) = _Note; + + factory Note.fromJson(Map json) => _$NoteFromJson(json); + + /// Get created date as DateTime + DateTime get createdDateTime => + DateTime.fromMicrosecondsSinceEpoch(createdAt ~/ 1000); + + /// Get updated date as DateTime + DateTime get updatedDateTime => + DateTime.fromMicrosecondsSinceEpoch(updatedAt ~/ 1000); + + /// Get the markdown content of the note + String get markdownContent => data.content.md; + + /// Get the HTML content of the note + String get htmlContent => data.content.html; + + /// Check if the note has content + bool get hasContent => + data.content.md.isNotEmpty || data.content.html.isNotEmpty; +} + +/// Converter for NoteData that handles both object and null cases. +class _NoteDataConverter implements JsonConverter { + const _NoteDataConverter(); + + @override + NoteData fromJson(Object? json) { + if (json == null) return const NoteData(); + if (json is Map) { + // Handle the nested content structure + final contentJson = json['content']; + NoteContent content = const NoteContent(); + if (contentJson is Map) { + content = NoteContent.fromJson(contentJson); + } + + // Handle versions + final versionsJson = json['versions']; + List versions = []; + if (versionsJson is List) { + versions = versionsJson + .whereType>() + .map((v) => NoteContent.fromJson(v)) + .toList(); + } + + // Handle files + final filesJson = json['files']; + List>? files; + if (filesJson is List) { + files = filesJson.whereType>().toList(); + } + + return NoteData(content: content, versions: versions, files: files); + } + return const NoteData(); + } + + @override + Object? toJson(NoteData object) => object.toJson(); +} + +/// Converter for metadata maps. +class _MetadataConverter + implements JsonConverter?, Object?> { + const _MetadataConverter(); + + @override + Map? fromJson(Object? json) { + if (json == null) return null; + if (json is Map) return json; + if (json is Map) { + return json.map((key, value) => MapEntry(key.toString(), value)); + } + return null; + } + + @override + Object? toJson(Map? object) => object; +} + +/// Form data for creating a new note. +@freezed +sealed class NoteForm with _$NoteForm { + const factory NoteForm({ + required String title, + NoteData? data, + Map? meta, + @JsonKey(name: 'access_control') Map? accessControl, + }) = _NoteForm; + + factory NoteForm.fromJson(Map json) => + _$NoteFormFromJson(json); +} + +/// Form data for updating a note. +@freezed +sealed class NoteUpdateForm with _$NoteUpdateForm { + const factory NoteUpdateForm({ + String? title, + NoteData? data, + Map? meta, + @JsonKey(name: 'access_control') Map? accessControl, + }) = _NoteUpdateForm; + + factory NoteUpdateForm.fromJson(Map json) => + _$NoteUpdateFormFromJson(json); +} diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index ac620e5..6694e1d 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -2156,6 +2156,22 @@ class FoldersFeatureEnabledNotifier extends Notifier { } } +/// Tracks whether the notes feature is enabled on the server. +/// When the server returns 403 for notes endpoint, this becomes false. +final notesFeatureEnabledProvider = + NotifierProvider( + NotesFeatureEnabledNotifier.new, + ); + +class NotesFeatureEnabledNotifier extends Notifier { + @override + bool build() => true; + + void setEnabled(bool enabled) { + state = enabled; + } +} + // Folders provider @Riverpod(keepAlive: true) class Folders extends _$Folders { diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 77dd499..71fdd4f 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -16,6 +16,8 @@ import '../../features/auth/views/connection_issue_page.dart'; import '../../features/auth/views/server_connection_page.dart'; import '../../features/chat/views/chat_page.dart'; import '../../features/navigation/views/splash_launcher_page.dart'; +import '../../features/notes/views/notes_list_page.dart'; +import '../../features/notes/views/note_editor_page.dart'; import '../../features/profile/views/app_customization_page.dart'; import '../../features/profile/views/profile_page.dart'; import '../../l10n/app_localizations.dart'; @@ -242,6 +244,22 @@ final goRouterProvider = Provider((ref) { name: RouteNames.appCustomization, builder: (context, state) => const AppCustomizationPage(), ), + GoRoute( + path: Routes.notes, + name: RouteNames.notes, + builder: (context, state) => const NotesListPage(), + ), + GoRoute( + path: Routes.noteEditor, + name: RouteNames.noteEditor, + builder: (context, state) { + final noteId = state.pathParameters['id']; + if (noteId == null || noteId.isEmpty) { + return const NotesListPage(); + } + return NoteEditorPage(noteId: noteId); + }, + ), ]; final router = GoRouter( diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 488770c..a260d94 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -2392,55 +2392,6 @@ class ApiService { return response.data as Map; } - Future>> getNotes() async { - _traceApi('Fetching notes'); - final response = await _dio.get('/api/v1/notes/'); - final data = response.data; - if (data is List) { - return data.cast>(); - } - return []; - } - - Future> createNote({ - required String title, - required String content, - List? tags, - }) async { - _traceApi('Creating note: $title'); - final response = await _dio.post( - '/api/v1/notes/', - data: { - 'title': title, - 'content': content, - if (tags != null) 'tags': tags, - }, - ); - return response.data as Map; - } - - Future updateNote( - String id, { - String? title, - String? content, - List? tags, - }) async { - _traceApi('Updating note: $id'); - await _dio.put( - '/api/v1/notes/$id', - data: { - if (title != null) 'title': title, - if (content != null) 'content': content, - if (tags != null) 'tags': tags, - }, - ); - } - - Future deleteNote(String id) async { - _traceApi('Deleting note: $id'); - await _dio.delete('/api/v1/notes/$id'); - } - // Team Collaboration Future>> getChannels() async { _traceApi('Fetching channels'); @@ -3585,6 +3536,226 @@ class ApiService { // ==================== END ADVANCED CHAT FEATURES ==================== + // ==================== NOTES ==================== + + /// Get all notes with user information. + /// Returns a record with (notes data, feature enabled flag). + /// When the notes feature is disabled server-side (403), returns ([], false). + Future<(List>, bool)> getNotes() async { + try { + _traceApi('Fetching notes'); + final response = await _dio.get('/api/v1/notes/'); + DebugLogger.log( + 'fetch-status', + scope: 'api/notes', + data: {'code': response.statusCode}, + ); + DebugLogger.log('fetch-ok', scope: 'api/notes'); + + final data = response.data; + if (data is List) { + _traceApi('Found ${data.length} notes'); + return (data.cast>(), true); + } else { + DebugLogger.warning( + 'unexpected-type', + scope: 'api/notes', + data: {'type': data.runtimeType}, + ); + return (const >[], true); + } + } on DioException catch (e) { + // 403 indicates notes feature is disabled server-side + if (e.response?.statusCode == 403) { + DebugLogger.log( + 'feature-disabled', + scope: 'api/notes', + data: {'status': 403}, + ); + return (const >[], false); + } + DebugLogger.error('fetch-failed', scope: 'api/notes', error: e); + rethrow; + } catch (e) { + DebugLogger.error('fetch-failed', scope: 'api/notes', error: e); + rethrow; + } + } + + /// Get paginated note list (title, id, timestamps only) + Future>> getNoteList({int? page}) async { + _traceApi('Fetching note list, page: $page'); + final queryParams = {}; + if (page != null) queryParams['page'] = page; + + final response = await _dio.get( + '/api/v1/notes/list', + queryParameters: queryParams.isNotEmpty ? queryParams : null, + ); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return []; + } + + /// Get a single note by ID + Future> getNoteById(String id) async { + _traceApi('Fetching note: $id'); + final response = await _dio.get('/api/v1/notes/$id'); + return response.data as Map; + } + + /// Create a new note + Future> createNote({ + required String title, + Map? data, + Map? meta, + Map? accessControl, + }) async { + _traceApi('Creating note: $title'); + final response = await _dio.post( + '/api/v1/notes/create', + data: { + 'title': title, + if (data != null) 'data': data, + if (meta != null) 'meta': meta, + if (accessControl != null) 'access_control': accessControl, + }, + ); + return response.data as Map; + } + + /// Update an existing note + Future> updateNote( + String id, { + String? title, + Map? data, + Map? meta, + Map? accessControl, + }) async { + _traceApi('Updating note: $id'); + final response = await _dio.post( + '/api/v1/notes/$id/update', + data: { + if (title != null) 'title': title, + if (data != null) 'data': data, + if (meta != null) 'meta': meta, + if (accessControl != null) 'access_control': accessControl, + }, + ); + return response.data as Map; + } + + /// Delete a note by ID + Future deleteNote(String id) async { + _traceApi('Deleting note: $id'); + final response = await _dio.delete('/api/v1/notes/$id/delete'); + return response.data == true; + } + + /// Generate a title for note content using AI + Future generateNoteTitle( + String content, { + required String modelId, + }) async { + _traceApi('Generating title for note content with model: $modelId'); + + final prompt = + '''### Task: +Generate a concise, 3-5 word title with an emoji summarizing the content in the content's primary language. +### Guidelines: +- The title should clearly represent the main theme or subject of the content. +- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting. +- Write the title in the content's primary language. +- Prioritize accuracy over excessive creativity; keep it clear and simple. +- Your entire response must consist solely of the JSON object, without any introductory or concluding text. +- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text. +- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure. +### Output: +JSON format: { "title": "your concise title here" } +### Examples: +- { "title": "๐Ÿ“‰ Stock Market Trends" }, +- { "title": "๐Ÿช Perfect Chocolate Chip Recipe" }, +- { "title": "Evolution of Music Streaming" }, +- { "title": "Remote Work Productivity Tips" }, +- { "title": "Artificial Intelligence in Healthcare" }, +- { "title": "๐ŸŽฎ Video Game Development Insights" } +### Content: + +$content +'''; + + try { + final response = await _dio.post( + '/api/chat/completions', + data: { + 'model': modelId, + 'stream': false, + 'messages': [ + {'role': 'user', 'content': prompt}, + ], + }, + ); + + final responseText = + response.data?['choices']?[0]?['message']?['content'] as String? ?? + ''; + + _traceApi('Title generation response: $responseText'); + + // Parse JSON from response + final jsonStart = responseText.indexOf('{'); + final jsonEnd = responseText.lastIndexOf('}'); + + if (jsonStart != -1 && jsonEnd != -1) { + final jsonStr = responseText.substring(jsonStart, jsonEnd + 1); + final parsed = jsonDecode(jsonStr) as Map; + return (parsed['title'] as String?)?.trim(); + } + } catch (e) { + _traceApi('Failed to generate note title: $e'); + rethrow; + } + return null; + } + + /// Enhance note content using AI + Future enhanceNoteContent( + String content, { + required String modelId, + }) async { + _traceApi('Enhancing note content with AI, model: $modelId'); + + const systemPrompt = + '''Enhance existing notes using the content's primary language. Your task is to make the notes more useful and comprehensive. + +# Output Format + +Provide the enhanced notes in markdown format. Use markdown syntax for headings, lists, task lists ([ ]) where tasks or checklists are strongly implied, and emphasis to improve clarity and presentation. Ensure that all integrated content is accurately reflected. Return only the markdown formatted note.'''; + + try { + final response = await _dio.post( + '/api/chat/completions', + data: { + 'model': modelId, + 'stream': false, + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + {'role': 'user', 'content': '$content'}, + ], + }, + ); + + return response.data?['choices']?[0]?['message']?['content'] as String?; + } catch (e) { + _traceApi('Failed to enhance note content: $e'); + rethrow; + } + } + + // ==================== END NOTES ==================== + // Legacy streaming wrapper methods removed } diff --git a/lib/core/services/navigation_service.dart b/lib/core/services/navigation_service.dart index 61a92ee..d583319 100644 --- a/lib/core/services/navigation_service.dart +++ b/lib/core/services/navigation_service.dart @@ -103,6 +103,8 @@ class Routes { static const String authentication = '/authentication'; static const String profile = '/profile'; static const String appCustomization = '/profile/customization'; + static const String notes = '/notes'; + static const String noteEditor = '/notes/:id'; } /// Friendly names for GoRouter routes to support context.pushNamed. @@ -115,4 +117,6 @@ class RouteNames { static const String authentication = 'authentication'; static const String profile = 'profile'; static const String appCustomization = 'app-customization'; + static const String notes = 'notes'; + static const String noteEditor = 'note-editor'; } diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index c60500f..d7be8ab 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -1620,6 +1620,7 @@ class _ChatsDrawerState extends ConsumerState { orElse: () => authUser, ); final api = ref.watch(apiServiceProvider); + final notesEnabled = ref.watch(notesFeatureEnabledProvider); String initialFor(String name) { if (name.isEmpty) return 'U'; @@ -1630,6 +1631,7 @@ class _ChatsDrawerState extends ConsumerState { final displayName = deriveUserDisplayName(user); final initial = initialFor(displayName); final avatarUrl = resolveUserAvatarUrlForUser(api, user); + return Padding( padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm), child: Column( @@ -1683,6 +1685,23 @@ class _ChatsDrawerState extends ConsumerState { ), ), ), + // Notes icon (hidden when feature is disabled) + if (notesEnabled) + IconButton( + tooltip: AppLocalizations.of(context)!.notes, + onPressed: () { + Navigator.of(context).maybePop(); + context.pushNamed(RouteNames.notes); + }, + visualDensity: VisualDensity.compact, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.doc_text + : Icons.note_alt_outlined, + color: sidebarTheme.foreground.withValues(alpha: 0.8), + size: IconSize.medium, + ), + ), IconButton( tooltip: AppLocalizations.of(context)!.manage, onPressed: () { diff --git a/lib/features/notes/providers/notes_providers.dart b/lib/features/notes/providers/notes_providers.dart new file mode 100644 index 0000000..645ffcc --- /dev/null +++ b/lib/features/notes/providers/notes_providers.dart @@ -0,0 +1,297 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:conduit/core/models/note.dart'; +import 'package:conduit/core/providers/app_providers.dart'; + +part 'notes_providers.g.dart'; + +/// Provider for the list of all notes with user information. +@riverpod +class NotesList extends _$NotesList { + @override + Future> build() async { + final api = ref.watch(apiServiceProvider); + if (api == null) return const []; + + final (rawNotes, featureEnabled) = await api.getNotes(); + + // Update the notes feature enabled state + ref.read(notesFeatureEnabledProvider.notifier).setEnabled(featureEnabled); + + return rawNotes.map((json) => Note.fromJson(json)).toList(); + } + + /// Refresh the notes list from the server. + Future refresh() async { + state = const AsyncValue.loading(); + final result = await AsyncValue.guard(() => build()); + if (!ref.mounted) return; + state = result; + } + + /// Add a newly created note to the list. + void addNote(Note note) { + final current = state.value ?? []; + state = AsyncValue.data([note, ...current]); + } + + /// Update an existing note in the list. + void updateNote(Note updatedNote) { + final current = state.value ?? []; + final updated = current.map((n) { + return n.id == updatedNote.id ? updatedNote : n; + }).toList(); + state = AsyncValue.data(updated); + } + + /// Remove a note from the list. + void removeNote(String noteId) { + final current = state.value ?? []; + final updated = current.where((n) => n.id != noteId).toList(); + state = AsyncValue.data(updated); + } +} + +/// Provider for a single note by ID. +@riverpod +Future noteById(Ref ref, String id) async { + final api = ref.watch(apiServiceProvider); + if (api == null) return null; + + final json = await api.getNoteById(id); + return Note.fromJson(json); +} + +/// Helper to group notes by time range. +enum TimeRange { + today, + yesterday, + previousSevenDays, + previousThirtyDays, + older, +} + +/// Determine which time range a timestamp belongs to. +/// Uses `!isBefore` instead of `isAfter` to include boundary timestamps +/// (e.g., exactly midnight) in the correct range. +TimeRange getTimeRangeForTimestamp(DateTime timestamp) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final sevenDaysAgo = today.subtract(const Duration(days: 7)); + final thirtyDaysAgo = today.subtract(const Duration(days: 30)); + + if (!timestamp.isBefore(today)) { + return TimeRange.today; + } else if (!timestamp.isBefore(yesterday)) { + return TimeRange.yesterday; + } else if (!timestamp.isBefore(sevenDaysAgo)) { + return TimeRange.previousSevenDays; + } else if (!timestamp.isBefore(thirtyDaysAgo)) { + return TimeRange.previousThirtyDays; + } else { + return TimeRange.older; + } +} + +/// Provider that returns notes grouped by time range. +@riverpod +Map> notesGroupedByTime(Ref ref) { + final notesAsync = ref.watch(notesListProvider); + final notes = notesAsync.value ?? []; + + final grouped = >{}; + + for (final note in notes) { + final range = getTimeRangeForTimestamp(note.updatedDateTime); + grouped.putIfAbsent(range, () => []).add(note); + } + + return grouped; +} + +/// Provider for notes filtered by search query. +@riverpod +List filteredNotes(Ref ref, String query) { + final notesAsync = ref.watch(notesListProvider); + final notes = notesAsync.value ?? []; + + if (query.isEmpty) return notes; + + final lowerQuery = query.toLowerCase(); + return notes.where((note) { + final titleMatch = note.title.toLowerCase().contains(lowerQuery); + final contentMatch = note.markdownContent.toLowerCase().contains( + lowerQuery, + ); + return titleMatch || contentMatch; + }).toList(); +} + +/// Provider for creating a new note. +@Riverpod(keepAlive: true) +class NoteCreator extends _$NoteCreator { + @override + AsyncValue build() => const AsyncValue.data(null); + + /// Create a new note and return it. + Future createNote({ + required String title, + String? markdownContent, + String? htmlContent, + }) async { + state = const AsyncValue.loading(); + + final api = ref.read(apiServiceProvider); + if (api == null) { + if (!ref.mounted) return null; + state = AsyncValue.error( + Exception('API service not available'), + StackTrace.current, + ); + return null; + } + + try { + final data = { + 'content': { + 'json': null, + 'html': htmlContent ?? '', + 'md': markdownContent ?? '', + }, + 'versions': [], + 'files': null, + }; + + final json = await api.createNote( + title: title, + data: data, + accessControl: {}, + ); + + if (!ref.mounted) return null; + + final note = Note.fromJson(json); + + // Add to the notes list + ref.read(notesListProvider.notifier).addNote(note); + + state = AsyncValue.data(note); + return note; + } catch (e, st) { + if (!ref.mounted) return null; + state = AsyncValue.error(e, st); + return null; + } + } +} + +/// Provider for updating an existing note. +@Riverpod(keepAlive: true) +class NoteUpdater extends _$NoteUpdater { + @override + AsyncValue build() => const AsyncValue.data(null); + + /// Update a note with new content. + Future updateNote( + String id, { + String? title, + String? markdownContent, + String? htmlContent, + Object? jsonContent, + }) async { + state = const AsyncValue.loading(); + + final api = ref.read(apiServiceProvider); + if (api == null) { + if (!ref.mounted) return null; + state = AsyncValue.error( + Exception('API service not available'), + StackTrace.current, + ); + return null; + } + + try { + Map? data; + if (markdownContent != null || + htmlContent != null || + jsonContent != null) { + data = { + 'content': { + 'json': jsonContent, + 'html': htmlContent ?? '', + 'md': markdownContent ?? '', + }, + }; + } + + final json = await api.updateNote(id, title: title, data: data); + + if (!ref.mounted) return null; + + final note = Note.fromJson(json); + + // Update in the notes list + ref.read(notesListProvider.notifier).updateNote(note); + + state = AsyncValue.data(note); + return note; + } catch (e, st) { + if (!ref.mounted) return null; + state = AsyncValue.error(e, st); + return null; + } + } +} + +/// Provider for deleting a note. +@Riverpod(keepAlive: true) +class NoteDeleter extends _$NoteDeleter { + @override + AsyncValue build() => const AsyncValue.data(false); + + /// Delete a note by ID. + Future deleteNote(String id) async { + state = const AsyncValue.loading(); + + final api = ref.read(apiServiceProvider); + if (api == null) { + if (!ref.mounted) return false; + state = AsyncValue.error( + Exception('API service not available'), + StackTrace.current, + ); + return false; + } + + try { + final success = await api.deleteNote(id); + + if (!ref.mounted) return false; + + if (success) { + // Remove from the notes list + ref.read(notesListProvider.notifier).removeNote(id); + } + + state = AsyncValue.data(success); + return success; + } catch (e, st) { + if (!ref.mounted) return false; + state = AsyncValue.error(e, st); + return false; + } + } +} + +/// Provider for the currently active/selected note. +@riverpod +class ActiveNote extends _$ActiveNote { + @override + Note? build() => null; + + void set(Note? note) => state = note; + + void clear() => state = null; +} diff --git a/lib/features/notes/views/note_editor_page.dart b/lib/features/notes/views/note_editor_page.dart new file mode 100644 index 0000000..aadb808 --- /dev/null +++ b/lib/features/notes/views/note_editor_page.dart @@ -0,0 +1,1042 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import 'package:conduit/l10n/app_localizations.dart'; +import '../../../core/models/note.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/utils/ui_utils.dart'; +import '../../../shared/widgets/improved_loading_states.dart'; +import '../../../shared/widgets/themed_dialogs.dart'; +import '../../chat/services/voice_input_service.dart'; +import '../providers/notes_providers.dart'; + +/// Page for editing a note with OpenWebUI-style layout. +class NoteEditorPage extends ConsumerStatefulWidget { + final String noteId; + + const NoteEditorPage({super.key, required this.noteId}); + + @override + ConsumerState createState() => _NoteEditorPageState(); +} + +class _NoteEditorPageState extends ConsumerState { + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _contentController = TextEditingController(); + final FocusNode _titleFocusNode = FocusNode(debugLabel: 'note_title'); + final FocusNode _contentFocusNode = FocusNode(debugLabel: 'note_content'); + final ScrollController _scrollController = ScrollController(); + + Timer? _saveDebounce; + bool _isLoading = true; + bool _isSaving = false; + bool _hasChanges = false; + bool _isGeneratingTitle = false; + bool _isEnhancing = false; + bool _isRecording = false; + Note? _note; + + // Voice input + VoiceInputService? _voiceService; + StreamSubscription? _voiceSub; + String _voiceBaseText = ''; + + int get _wordCount { + final text = _contentController.text.trim(); + if (text.isEmpty) return 0; + return text.split(RegExp(r'\s+')).length; + } + + int get _charCount => _contentController.text.length; + + @override + void initState() { + super.initState(); + _loadNote(); + _titleController.addListener(_onContentChanged); + _contentController.addListener(_onContentChanged); + // Rebuild when title focus changes to show/hide the generate title button + _titleFocusNode.addListener(_onTitleFocusChanged); + } + + void _onTitleFocusChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _saveDebounce?.cancel(); + _voiceSub?.cancel(); + _voiceService?.stopListening(); + _titleController.dispose(); + _contentController.dispose(); + _titleFocusNode.removeListener(_onTitleFocusChanged); + _titleFocusNode.dispose(); + _contentFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadNote() async { + setState(() => _isLoading = true); + + final api = ref.read(apiServiceProvider); + if (api == null) { + setState(() => _isLoading = false); + return; + } + + try { + final json = await api.getNoteById(widget.noteId); + final note = Note.fromJson(json); + + if (mounted) { + setState(() { + _note = note; + _titleController.text = note.title; + _contentController.text = note.markdownContent; + _isLoading = false; + _hasChanges = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + _showError(e.toString()); + } + } + } + + void _onContentChanged() { + if (!_hasChanges && mounted) { + setState(() => _hasChanges = true); + } + _debounceSave(); + } + + void _debounceSave() { + _saveDebounce?.cancel(); + _saveDebounce = Timer(const Duration(milliseconds: 800), _autoSave); + } + + Future _autoSave() async { + if (_note == null || !_hasChanges) return; + await _saveNote(showFeedback: false); + } + + Future _saveNote({bool showFeedback = true}) async { + if (_note == null) return; + + setState(() => _isSaving = true); + + final api = ref.read(apiServiceProvider); + if (api == null) { + setState(() => _isSaving = false); + return; + } + + try { + final title = _titleController.text.trim(); + final content = _contentController.text; + + final data = { + 'content': { + 'json': null, + 'html': _markdownToHtml(content), + 'md': content, + }, + }; + + // Use the server's response to get authoritative data (including updated_at) + final json = await api.updateNote( + widget.noteId, + title: title.isEmpty ? AppLocalizations.of(context)!.untitled : title, + data: data, + ); + + final updatedNote = Note.fromJson(json); + + ref.read(notesListProvider.notifier).updateNote(updatedNote); + + if (mounted) { + setState(() { + _note = updatedNote; + _isSaving = false; + _hasChanges = false; + }); + + if (showFeedback) { + HapticFeedback.lightImpact(); + } + } + } catch (e) { + if (mounted) { + setState(() => _isSaving = false); + _showError(e.toString()); + } + } + } + + String _markdownToHtml(String markdown) { + final paragraphs = markdown.split('\n\n'); + final html = paragraphs + .map((p) { + if (p.trim().isEmpty) return ''; + if (p.startsWith('# ')) { + return '

${_escapeHtml(p.substring(2))}

'; + } + if (p.startsWith('## ')) { + return '

${_escapeHtml(p.substring(3))}

'; + } + if (p.startsWith('### ')) { + return '

${_escapeHtml(p.substring(4))}

'; + } + // Escape entire paragraph first to prevent XSS, then apply + // markdown formatting replacements on the escaped text. + var text = _escapeHtml(p); + text = text.replaceAllMapped( + RegExp(r'\*\*(.+?)\*\*'), + (m) => '${m.group(1)!}', + ); + text = text.replaceAllMapped( + RegExp(r'\*(.+?)\*'), + (m) => '${m.group(1)!}', + ); + return '

$text

'; + }) + .join('\n'); + return html; + } + + String _escapeHtml(String text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: context.conduitTheme.error, + ), + ); + } + + Future _onWillPop() async { + if (_hasChanges) { + await _saveNote(showFeedback: false); + } + return true; + } + + Future _deleteNote() async { + if (_note == null) return; + + final l10n = AppLocalizations.of(context)!; + final confirmed = await ThemedDialogs.confirm( + context, + title: l10n.deleteNoteTitle, + message: l10n.deleteNoteMessage( + _note!.title.isEmpty ? l10n.untitled : _note!.title, + ), + confirmText: l10n.delete, + isDestructive: true, + ); + + if (confirmed && mounted) { + HapticFeedback.mediumImpact(); + final success = await ref + .read(noteDeleterProvider.notifier) + .deleteNote(widget.noteId); + if (success && mounted) { + Navigator.of(context).pop(); + } + } + } + + // Get the selected model ID for AI operations + String? _getSelectedModelId() { + final selectedModel = ref.read(selectedModelProvider); + return selectedModel?.id; + } + + // AI title generation + Future _generateTitle() async { + if (_note == null || _isGeneratingTitle) return; + final content = _contentController.text.trim(); + if (content.isEmpty) { + _showError(AppLocalizations.of(context)!.noContentToGenerateTitle); + return; + } + + final modelId = _getSelectedModelId(); + if (modelId == null) { + _showError(AppLocalizations.of(context)!.noModelSelected); + return; + } + + setState(() => _isGeneratingTitle = true); + HapticFeedback.lightImpact(); + + final api = ref.read(apiServiceProvider); + if (api == null) { + setState(() => _isGeneratingTitle = false); + return; + } + + try { + final generatedTitle = await api.generateNoteTitle( + content, + modelId: modelId, + ); + if (mounted && generatedTitle != null && generatedTitle.isNotEmpty) { + _titleController.text = generatedTitle; + HapticFeedback.mediumImpact(); + } + } catch (e) { + if (mounted) { + _showError(AppLocalizations.of(context)!.failedToGenerateTitle); + } + } finally { + if (mounted) { + setState(() => _isGeneratingTitle = false); + } + } + } + + // AI content enhancement + Future _enhanceContent() async { + if (_note == null || _isEnhancing) return; + final content = _contentController.text.trim(); + if (content.isEmpty) { + _showError(AppLocalizations.of(context)!.noContentToEnhance); + return; + } + + final modelId = _getSelectedModelId(); + if (modelId == null) { + _showError(AppLocalizations.of(context)!.noModelSelected); + return; + } + + setState(() => _isEnhancing = true); + HapticFeedback.lightImpact(); + + final api = ref.read(apiServiceProvider); + if (api == null) { + setState(() => _isEnhancing = false); + return; + } + + try { + final enhancedContent = await api.enhanceNoteContent( + content, + modelId: modelId, + ); + if (mounted && enhancedContent != null && enhancedContent.isNotEmpty) { + _contentController.text = enhancedContent; + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.noteEnhanced), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + _showError(AppLocalizations.of(context)!.failedToEnhanceNote); + } + } finally { + if (mounted) { + setState(() => _isEnhancing = false); + } + } + } + + // Voice dictation + Future _toggleDictation() async { + if (_isRecording) { + await _stopDictation(); + } else { + await _startDictation(); + } + } + + Future _startDictation() async { + _voiceService ??= VoiceInputService( + api: ref.read(apiServiceProvider), + ); + + try { + final ok = await _voiceService!.initialize(); + if (!mounted) return; + if (!ok) { + _showError(AppLocalizations.of(context)!.voiceInputUnavailable); + return; + } + + final stream = await _voiceService!.beginListening(); + if (!mounted) return; + + setState(() { + _isRecording = true; + _voiceBaseText = _contentController.text; + }); + + HapticFeedback.lightImpact(); + + _voiceSub?.cancel(); + _voiceSub = stream.listen( + (text) { + if (!mounted) return; + final updated = _voiceBaseText.isEmpty + ? text + : '${_voiceBaseText.trimRight()} $text'; + _contentController.value = TextEditingValue( + text: updated, + selection: TextSelection.collapsed(offset: updated.length), + ); + }, + onDone: () { + if (!mounted) return; + setState(() => _isRecording = false); + }, + onError: (_) { + if (!mounted) return; + setState(() => _isRecording = false); + }, + ); + } catch (e) { + _showError(AppLocalizations.of(context)!.failedToStartDictation); + if (mounted) { + setState(() => _isRecording = false); + } + } + } + + Future _stopDictation() async { + await _voiceService?.stopListening(); + _voiceSub?.cancel(); + if (mounted) { + setState(() => _isRecording = false); + HapticFeedback.selectionClick(); + } + } + + void _copyToClipboard() { + final l10n = AppLocalizations.of(context)!; + final content = _contentController.text; + Clipboard.setData(ClipboardData(text: content)); + HapticFeedback.selectionClick(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.noteCopiedToClipboard), + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final sidebarTheme = context.sidebarTheme; + + // Check if notes feature is enabled - redirect to chat if disabled + final notesEnabled = ref.watch(notesFeatureEnabledProvider); + if (!notesEnabled) { + // Redirect back to chat on next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.go('/chat'); + } + }); + // Show empty scaffold while redirecting + return Scaffold(backgroundColor: sidebarTheme.background); + } + + return PopScope( + // Only allow immediate pop when there are no unsaved changes. + // When there are changes, we intercept, save first, then pop manually. + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; // Already popped, nothing to do + // Save changes before allowing pop + await _saveNote(showFeedback: false); + if (!mounted) return; + Navigator.of(context).pop(); + }, + child: ErrorBoundary( + child: Scaffold( + backgroundColor: sidebarTheme.background, + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + _buildHeader(context), + if (!_isLoading && _note != null) + _buildMetadataBar(context), + Expanded(child: _buildBody(context)), + ], + ), + // Floating action buttons + if (!_isLoading && _note != null) + _buildFloatingActions(context), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final theme = context.conduitTheme; + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + + return Container( + padding: const EdgeInsets.fromLTRB( + Spacing.xs, + Spacing.sm, + Spacing.sm, + Spacing.xs, + ), + color: sidebarTheme.background, + child: Row( + children: [ + // Back button + IconButton( + icon: Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.back, + android: Icons.arrow_back_rounded, + ), + color: theme.iconPrimary, + ), + onPressed: () async { + final navigator = Navigator.of(context); + await _onWillPop(); + if (!mounted) return; + navigator.pop(); + }, + tooltip: l10n.back, + ), + const SizedBox(width: Spacing.xs), + + // Title input + Expanded( + child: TextField( + controller: _titleController, + focusNode: _titleFocusNode, + enabled: !_isGeneratingTitle, + style: AppTypography.headlineSmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + decoration: InputDecoration( + hintText: _isGeneratingTitle + ? l10n.generatingTitle + : l10n.noteTitle, + hintStyle: AppTypography.headlineSmallStyle.copyWith( + color: theme.textSecondary.withValues(alpha: 0.4), + fontWeight: FontWeight.w600, + ), + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.next, + onSubmitted: (_) => _contentFocusNode.requestFocus(), + ), + ), + + // Generate title button - aligned with other header icons + AnimatedOpacity( + opacity: _titleFocusNode.hasFocus && !_isGeneratingTitle ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + child: IgnorePointer( + ignoring: !_titleFocusNode.hasFocus || _isGeneratingTitle, + child: IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.sparkles + : Icons.auto_awesome_rounded, + color: theme.buttonPrimary, + ), + onPressed: _generateTitle, + tooltip: l10n.generateTitle, + ), + ), + ), + + // Save indicator + if (_isSaving) + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + child: SizedBox( + width: IconSize.sm, + height: IconSize.sm, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: AlwaysStoppedAnimation(theme.loadingIndicator), + ), + ), + ) + else if (_hasChanges) + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.warning, + shape: BoxShape.circle, + ), + ), + ), + + // Menu + PopupMenuButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.ellipsis + : Icons.more_vert_rounded, + color: theme.iconPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + onSelected: (value) { + switch (value) { + case 'copy': + _copyToClipboard(); + case 'delete': + _deleteNote(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'copy', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.copy_rounded, + color: theme.iconPrimary, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text(l10n.copy), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.delete + : Icons.delete_rounded, + color: theme.error, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text(l10n.delete, style: TextStyle(color: theme.error)), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildMetadataBar(BuildContext context) { + final theme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + final dateFormat = DateFormat.MMMd(); + final timeFormat = DateFormat.jm(); + final createdDate = _note != null + ? '${dateFormat.format(_note!.createdDateTime)} ${timeFormat.format(_note!.createdDateTime)}' + : ''; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // Created date + _buildMetadataChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.calendar + : Icons.calendar_today_rounded, + label: createdDate, + ), + _buildMetadataSeparator(theme), + // Word count + _buildMetadataChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.doc_text + : Icons.article_rounded, + label: l10n.wordCount(_wordCount), + ), + _buildMetadataSeparator(theme), + // Character count + _buildMetadataChip( + context, + icon: Platform.isIOS + ? CupertinoIcons.textformat_abc + : Icons.text_fields_rounded, + label: l10n.charCount(_charCount), + ), + ], + ), + ), + ); + } + + Widget _buildMetadataSeparator(ConduitThemeExtension theme) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.xxs), + child: Text( + 'ยท', + style: AppTypography.tiny.copyWith( + color: theme.textTertiary.withValues(alpha: 0.5), + ), + ), + ); + } + + Widget _buildMetadataChip( + BuildContext context, { + required IconData icon, + required String label, + }) { + final theme = context.conduitTheme; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: theme.textTertiary.withValues(alpha: 0.7), + size: IconSize.xs, + ), + const SizedBox(width: Spacing.xxs), + Text( + label, + style: AppTypography.tiny.copyWith( + color: theme.textTertiary.withValues(alpha: 0.7), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildBody(BuildContext context) { + if (_isLoading) { + return Center( + child: ImprovedLoadingState( + message: AppLocalizations.of(context)!.loadingNote, + ), + ); + } + + if (_note == null) { + return _buildNotFoundState(context); + } + + return _buildEditor(context); + } + + Widget _buildEditor(BuildContext context) { + final theme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + return GestureDetector( + onTap: () => _contentFocusNode.requestFocus(), + behavior: HitTestBehavior.opaque, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB( + Spacing.inputPadding, + Spacing.md, + Spacing.inputPadding, + 120, // Extra padding for floating buttons + ), + child: TextField( + controller: _contentController, + focusNode: _contentFocusNode, + style: AppTypography.bodyLargeStyle.copyWith( + color: theme.textPrimary, + height: 1.8, + ), + decoration: InputDecoration( + hintText: l10n.writeNote, + hintStyle: AppTypography.bodyLargeStyle.copyWith( + color: theme.textSecondary.withValues(alpha: 0.35), + height: 1.8, + ), + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + maxLines: null, + minLines: 20, + textAlignVertical: TextAlignVertical.top, + textCapitalization: TextCapitalization.sentences, + keyboardType: TextInputType.multiline, + ), + ), + ); + } + + Widget _buildFloatingActions(BuildContext context) { + final theme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + return Positioned( + left: Spacing.md, + right: Spacing.md, + bottom: Spacing.md, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Dictation button + _buildFloatingButton( + context, + icon: _isRecording + ? (Platform.isIOS + ? CupertinoIcons.stop_fill + : Icons.stop_rounded) + : (Platform.isIOS + ? CupertinoIcons.mic_fill + : Icons.mic_rounded), + color: _isRecording ? theme.error : null, + isLoading: false, + tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation, + onPressed: _toggleDictation, + ), + + // AI button + _buildFloatingButton( + context, + icon: Platform.isIOS + ? CupertinoIcons.sparkles + : Icons.auto_awesome_rounded, + isLoading: _isEnhancing, + tooltip: l10n.enhanceWithAI, + onPressed: _isEnhancing ? null : _enhanceContent, + showMenu: true, + ), + ], + ), + ); + } + + Widget _buildFloatingButton( + BuildContext context, { + required IconData icon, + required String tooltip, + required VoidCallback? onPressed, + bool isLoading = false, + Color? color, + bool showMenu = false, + }) { + final theme = context.conduitTheme; + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + + final buttonChild = Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: theme.surfaceContainer, + shape: BoxShape.circle, + border: Border.all( + color: sidebarTheme.border.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + boxShadow: ConduitShadows.medium(context), + ), + child: isLoading + ? Center( + child: SizedBox( + width: IconSize.md, + height: IconSize.md, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: AlwaysStoppedAnimation(theme.loadingIndicator), + ), + ), + ) + : Icon( + icon, + color: color ?? theme.iconPrimary, + size: IconSize.lg, + ), + ); + + if (showMenu) { + return PopupMenuButton( + tooltip: tooltip, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + offset: const Offset(0, -120), + onSelected: (value) { + switch (value) { + case 'enhance': + _enhanceContent(); + case 'title': + _generateTitle(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'enhance', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.sparkles + : Icons.auto_fix_high_rounded, + color: theme.buttonPrimary, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text(l10n.enhanceNote), + ], + ), + ), + PopupMenuItem( + value: 'title', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.textformat + : Icons.title_rounded, + color: theme.buttonPrimary, + size: IconSize.md, + ), + const SizedBox(width: Spacing.sm), + Text(l10n.generateTitle), + ], + ), + ), + ], + child: buttonChild, + ); + } + + return Tooltip( + message: tooltip, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: buttonChild, + ), + ), + ); + } + + Widget _buildNotFoundState(BuildContext context) { + final theme = context.conduitTheme; + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.xxl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: sidebarTheme.accent.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.xl), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.doc_text + : Icons.description_outlined, + size: 36, + color: sidebarTheme.foreground.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: Spacing.lg), + Text( + l10n.noteNotFound, + style: AppTypography.headlineSmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.lg), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: Icon( + Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back_rounded, + ), + label: Text(l10n.goBack), + style: OutlinedButton.styleFrom( + foregroundColor: sidebarTheme.primary, + side: BorderSide( + color: sidebarTheme.primary.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/notes/views/notes_list_page.dart b/lib/features/notes/views/notes_list_page.dart new file mode 100644 index 0000000..dc67316 --- /dev/null +++ b/lib/features/notes/views/notes_list_page.dart @@ -0,0 +1,858 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import 'package:conduit/l10n/app_localizations.dart'; +import '../../../core/models/note.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/navigation_service.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/utils/ui_utils.dart'; +import '../../../shared/widgets/improved_loading_states.dart'; +import '../../../shared/widgets/themed_dialogs.dart'; +import '../../../shared/widgets/middle_ellipsis_text.dart'; +import '../../../shared/utils/conversation_context_menu.dart'; +import '../providers/notes_providers.dart'; + +/// Page displaying the list of all notes with search and time grouping. +class NotesListPage extends ConsumerStatefulWidget { + const NotesListPage({super.key}); + + @override + ConsumerState createState() => _NotesListPageState(); +} + +class _NotesListPageState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(debugLabel: 'notes_search'); + final ScrollController _scrollController = ScrollController(); + Timer? _debounce; + String _query = ''; + + // Section expansion state + final Map _expandedSections = {}; + + @override + void initState() { + super.initState(); + // Default all sections to expanded + for (final range in TimeRange.values) { + _expandedSections[range] = true; + } + } + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + _searchFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 250), () { + if (!mounted) return; + setState(() => _query = _searchController.text.trim()); + }); + } + + Future _refreshNotes() async { + HapticFeedback.lightImpact(); + await ref.read(notesListProvider.notifier).refresh(); + } + + Future _createNewNote() async { + HapticFeedback.lightImpact(); + + final dateFormat = DateFormat('yyyy-MM-dd'); + final defaultTitle = dateFormat.format(DateTime.now()); + + final note = await ref + .read(noteCreatorProvider.notifier) + .createNote(title: defaultTitle); + + if (note != null && mounted) { + context.pushNamed(RouteNames.noteEditor, pathParameters: {'id': note.id}); + } + } + + Future _deleteNote(Note note) async { + final l10n = AppLocalizations.of(context)!; + + final confirmed = await ThemedDialogs.confirm( + context, + title: l10n.deleteNoteTitle, + message: l10n.deleteNoteMessage( + note.title.isEmpty ? l10n.untitled : note.title, + ), + confirmText: l10n.delete, + isDestructive: true, + ); + + if (confirmed && mounted) { + HapticFeedback.mediumImpact(); + await ref.read(noteDeleterProvider.notifier).deleteNote(note.id); + } + } + + @override + Widget build(BuildContext context) { + final sidebarTheme = context.sidebarTheme; + + // Check if notes feature is enabled - redirect to chat if disabled + final notesEnabled = ref.watch(notesFeatureEnabledProvider); + if (!notesEnabled) { + // Redirect back to chat on next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + context.go('/chat'); + } + }); + // Show empty scaffold while redirecting + return Scaffold(backgroundColor: sidebarTheme.background); + } + + return ErrorBoundary( + child: Scaffold( + backgroundColor: sidebarTheme.background, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context), + _buildSearchField(context), + Expanded(child: _buildBody(context)), + ], + ), + ), + floatingActionButton: _buildFAB(context), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + final canPop = ModalRoute.of(context)?.canPop ?? false; + + return Container( + padding: EdgeInsets.fromLTRB( + canPop ? Spacing.xs : Spacing.inputPadding, + Spacing.md, + Spacing.inputPadding, + Spacing.sm, + ), + child: Row( + children: [ + if (canPop) ...[ + IconButton( + icon: Icon( + UiUtils.platformIcon( + ios: CupertinoIcons.back, + android: Icons.arrow_back, + ), + color: sidebarTheme.foreground.withValues(alpha: 0.8), + ), + onPressed: () => Navigator.of(context).maybePop(), + tooltip: l10n.back, + ), + const SizedBox(width: Spacing.xs), + ], + Icon( + Platform.isIOS ? CupertinoIcons.doc_text_fill : Icons.notes_rounded, + color: sidebarTheme.foreground.withValues(alpha: 0.7), + size: IconSize.lg, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text( + l10n.notes, + style: AppTypography.headlineSmallStyle.copyWith( + color: sidebarTheme.foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } + + Widget _buildSearchField(BuildContext context) { + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + + return Padding( + padding: const EdgeInsets.fromLTRB( + Spacing.inputPadding, + Spacing.xs, + Spacing.inputPadding, + Spacing.sm, + ), + child: Material( + color: Colors.transparent, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (_) => _onSearchChanged(), + style: AppTypography.standard.copyWith( + color: sidebarTheme.foreground, + ), + decoration: InputDecoration( + isDense: true, + hintText: l10n.searchNotes, + hintStyle: AppTypography.standard.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.5), + ), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.search : Icons.search_rounded, + color: sidebarTheme.foreground.withValues(alpha: 0.6), + size: IconSize.input, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, + ), + suffixIcon: _query.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + _searchFocusNode.unfocus(); + }, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.clear_circled_solid + : Icons.clear_rounded, + color: sidebarTheme.foreground.withValues(alpha: 0.6), + size: IconSize.input, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints( + minWidth: TouchTarget.minimum, + minHeight: TouchTarget.minimum, + ), + filled: true, + fillColor: sidebarTheme.accent.withValues(alpha: 0.85), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: BorderSide( + color: sidebarTheme.border.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + borderSide: BorderSide( + color: sidebarTheme.ring.withValues(alpha: 0.5), + width: BorderWidth.regular, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm, + ), + ), + ), + ), + ); + } + + Widget _buildBody(BuildContext context) { + final notesAsync = ref.watch(notesListProvider); + + return notesAsync.when( + data: (notes) => _buildNotesList(context, notes), + loading: () => _buildLoading(context), + error: (error, stack) => _buildError(context, error), + ); + } + + Widget _buildNotesList(BuildContext context, List allNotes) { + final notes = _query.isEmpty + ? allNotes + : ref.watch(filteredNotesProvider(_query)); + + if (notes.isEmpty) { + return _buildEmptyState(context); + } + + // Group notes by time range + final grouped = >{}; + for (final note in notes) { + final range = getTimeRangeForTimestamp(note.updatedDateTime); + grouped.putIfAbsent(range, () => []).add(note); + } + + // Build slivers + final slivers = []; + + for (final range in TimeRange.values) { + final rangeNotes = grouped[range]; + if (rangeNotes != null && rangeNotes.isNotEmpty) { + // Section header + slivers.add( + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + sliver: SliverToBoxAdapter( + child: _buildSectionHeader(context, range, rangeNotes.length), + ), + ), + ); + + // Notes in section + if (_expandedSections[range] ?? true) { + slivers.add( + const SliverToBoxAdapter(child: SizedBox(height: Spacing.xs)), + ); + slivers.add( + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + _buildNoteCard(context, rangeNotes[index]), + childCount: rangeNotes.length, + ), + ), + ), + ); + } + + slivers.add( + const SliverToBoxAdapter(child: SizedBox(height: Spacing.md)), + ); + } + } + + // Add bottom padding for FAB + slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 80))); + + return _buildRefreshableScrollView(slivers); + } + + Widget _buildRefreshableScrollView(List slivers) { + if (Platform.isIOS) { + return CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + CupertinoSliverRefreshControl(onRefresh: _refreshNotes), + ...slivers, + ], + ); + } + + return RefreshIndicator( + onRefresh: _refreshNotes, + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: slivers, + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, TimeRange range, int count) { + final theme = context.conduitTheme; + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + final isExpanded = _expandedSections[range] ?? true; + + String label; + switch (range) { + case TimeRange.today: + label = l10n.today; + case TimeRange.yesterday: + label = l10n.yesterday; + case TimeRange.previousSevenDays: + label = l10n.previous7Days; + case TimeRange.previousThirtyDays: + label = l10n.previous30Days; + case TimeRange.older: + label = l10n.older; + } + + return InkWell( + onTap: () { + HapticFeedback.selectionClick(); + setState(() => _expandedSections[range] = !isExpanded); + }, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: Spacing.sm, + horizontal: Spacing.xs, + ), + child: Row( + children: [ + AnimatedRotation( + turns: isExpanded ? 0.25 : 0, + duration: AnimationDuration.fast, + curve: Curves.easeOutCubic, + child: Icon( + Platform.isIOS + ? CupertinoIcons.chevron_right + : Icons.chevron_right_rounded, + color: sidebarTheme.foreground.withValues(alpha: 0.5), + size: IconSize.sm, + ), + ), + const SizedBox(width: Spacing.xs), + Text( + label, + style: AppTypography.labelStyle.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.8), + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + const SizedBox(width: Spacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: theme.buttonPrimary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + ), + child: Text( + '$count', + style: AppTypography.tiny.copyWith( + color: theme.buttonPrimary.withValues(alpha: 0.9), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildNoteCard(BuildContext context, Note note) { + final theme = context.conduitTheme; + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + + final timeFormat = DateFormat.jm(); + final dateFormat = DateFormat.MMMd(); + final isToday = _isToday(note.updatedDateTime); + final timeText = isToday + ? timeFormat.format(note.updatedDateTime) + : dateFormat.format(note.updatedDateTime); + + final title = note.title.isEmpty ? l10n.untitled : note.title; + final preview = note.markdownContent.replaceAll('\n', ' ').trim(); + final hasContent = preview.isNotEmpty; + + Color? overlayForStates(Set states) { + if (states.contains(WidgetState.pressed)) { + return theme.buttonPrimary.withValues(alpha: Alpha.buttonPressed); + } + if (states.contains(WidgetState.hovered) || + states.contains(WidgetState.focused)) { + return theme.buttonPrimary.withValues(alpha: Alpha.hover); + } + return Colors.transparent; + } + + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Container( + decoration: BoxDecoration( + color: sidebarTheme.accent.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all( + color: sidebarTheme.border.withValues(alpha: 0.15), + width: BorderWidth.thin, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.card), + overlayColor: WidgetStateProperty.resolveWith(overlayForStates), + onTap: () { + HapticFeedback.selectionClick(); + context.pushNamed( + RouteNames.noteEditor, + pathParameters: {'id': note.id}, + ); + }, + onLongPress: () => _showNoteContextMenu(context, note), + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Note icon + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: sidebarTheme.accent, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: sidebarTheme.border.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.doc_text_fill + : Icons.description_rounded, + color: sidebarTheme.foreground.withValues(alpha: 0.6), + size: IconSize.md, + ), + ), + const SizedBox(width: Spacing.md), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + MiddleEllipsisText( + title, + style: AppTypography.bodyMediumStyle.copyWith( + color: sidebarTheme.foreground, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + if (hasContent) ...[ + const SizedBox(height: Spacing.xxs), + Text( + preview, + style: AppTypography.bodySmallStyle.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.6), + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: Spacing.sm), + // Metadata row + Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.clock + : Icons.schedule_rounded, + color: sidebarTheme.foreground.withValues(alpha: 0.4), + size: 12, + ), + const SizedBox(width: 4), + Text( + timeText, + style: AppTypography.tiny.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.5), + fontWeight: FontWeight.w500, + ), + ), + if (note.user != null && note.user!.name != null) ...[ + const SizedBox(width: Spacing.sm), + Text( + 'ยท', + style: AppTypography.tiny.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.3), + ), + ), + const SizedBox(width: Spacing.sm), + Flexible( + child: Text( + note.user!.name!, + style: AppTypography.tiny.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.5), + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ], + ), + ), + // More button + Builder( + builder: (buttonContext) => IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.ellipsis + : Icons.more_vert_rounded, + color: sidebarTheme.foreground.withValues(alpha: 0.5), + size: IconSize.md, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: TouchTarget.badge, + minHeight: TouchTarget.badge, + ), + onPressed: () => _showNoteContextMenu(buttonContext, note), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + bool _isToday(DateTime date) { + final now = DateTime.now(); + return date.year == now.year && + date.month == now.month && + date.day == now.day; + } + + void _showNoteContextMenu(BuildContext context, Note note) { + final l10n = AppLocalizations.of(context)!; + + showConduitContextMenu( + context: context, + actions: [ + ConduitContextMenuAction( + cupertinoIcon: CupertinoIcons.pencil, + materialIcon: Icons.edit_rounded, + label: l10n.edit, + onBeforeClose: () => HapticFeedback.selectionClick(), + onSelected: () async { + context.pushNamed( + RouteNames.noteEditor, + pathParameters: {'id': note.id}, + ); + }, + ), + ConduitContextMenuAction( + cupertinoIcon: CupertinoIcons.doc_on_clipboard, + materialIcon: Icons.copy_rounded, + label: l10n.copy, + onBeforeClose: () => HapticFeedback.selectionClick(), + onSelected: () async { + final messenger = ScaffoldMessenger.of(context); + await Clipboard.setData(ClipboardData(text: note.markdownContent)); + if (!mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text(l10n.noteCopiedToClipboard), + duration: const Duration(seconds: 2), + ), + ); + }, + ), + ConduitContextMenuAction( + cupertinoIcon: CupertinoIcons.delete, + materialIcon: Icons.delete_rounded, + label: l10n.delete, + destructive: true, + onBeforeClose: () => HapticFeedback.mediumImpact(), + onSelected: () async => _deleteNote(note), + ), + ], + ); + } + + Widget _buildEmptyState(BuildContext context) { + final theme = context.conduitTheme; + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + final isSearchActive = _query.isNotEmpty; + + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.xxl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: sidebarTheme.accent.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + ), + child: Icon( + isSearchActive + ? (Platform.isIOS + ? CupertinoIcons.search + : Icons.search_off_rounded) + : (Platform.isIOS + ? CupertinoIcons.doc_text + : Icons.note_add_rounded), + size: 32, + color: sidebarTheme.foreground.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: Spacing.lg), + Text( + isSearchActive ? l10n.noNotesFound : l10n.noNotesYet, + style: AppTypography.bodyLargeStyle.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.8), + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.xs), + Text( + isSearchActive + ? l10n.tryDifferentSearch + : l10n.createFirstNoteHint, + style: AppTypography.bodySmallStyle.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.5), + ), + textAlign: TextAlign.center, + ), + if (!isSearchActive) ...[ + const SizedBox(height: Spacing.lg), + FilledButton.icon( + onPressed: _createNewNote, + icon: Icon( + Platform.isIOS ? CupertinoIcons.add : Icons.add_rounded, + ), + label: Text(l10n.createNote), + style: FilledButton.styleFrom( + backgroundColor: theme.buttonPrimary, + foregroundColor: theme.buttonPrimaryText, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.md, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildLoading(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Center(child: ImprovedLoadingState(message: l10n.loadingNotes)); + } + + Widget _buildError(BuildContext context, Object error) { + final theme = context.conduitTheme; + final sidebarTheme = context.sidebarTheme; + final l10n = AppLocalizations.of(context)!; + + return Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.xxl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: sidebarTheme.accent.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_triangle + : Icons.error_outline_rounded, + size: 32, + color: theme.error, + ), + ), + const SizedBox(height: Spacing.md), + Text( + l10n.failedToLoadNotes, + style: AppTypography.bodyMediumStyle.copyWith( + color: sidebarTheme.foreground.withValues(alpha: 0.8), + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: Spacing.lg), + OutlinedButton.icon( + onPressed: _refreshNotes, + icon: Icon( + Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh_rounded, + ), + label: Text(l10n.retry), + style: OutlinedButton.styleFrom( + foregroundColor: sidebarTheme.foreground.withValues(alpha: 0.8), + side: BorderSide( + color: sidebarTheme.border.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.button), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFAB(BuildContext context) { + final theme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: theme.buttonPrimary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 4), + ), + BoxShadow( + color: theme.buttonPrimary.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: FloatingActionButton( + onPressed: _createNewNote, + backgroundColor: theme.buttonPrimary, + foregroundColor: theme.buttonPrimaryText, + elevation: 0, + highlightElevation: 2, + tooltip: l10n.createNote, + child: Icon(Platform.isIOS ? CupertinoIcons.add : Icons.add_rounded), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c19960d..50571ca 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1586,5 +1586,205 @@ "transportModeWsInfo": "Lower overhead, but may fail behind strict proxies/firewalls.", "@transportModeWsInfo": { "description": "Footnote text for the WebSocket-only transport mode." + }, + "notes": "Notes", + "@notes": { + "description": "Navigation item and page title for notes feature." + }, + "searchNotes": "Search notes...", + "@searchNotes": { + "description": "Placeholder text for notes search field." + }, + "createNote": "Create Note", + "@createNote": { + "description": "Tooltip/button label for creating a new note." + }, + "noNotesYet": "No notes yet", + "@noNotesYet": { + "description": "Empty state title when user has no notes." + }, + "noNotesFound": "No notes found", + "@noNotesFound": { + "description": "Empty state title when search returns no results." + }, + "createFirstNoteHint": "Tap the + button to create your first note.", + "@createFirstNoteHint": { + "description": "Hint text encouraging user to create their first note." + }, + "tryDifferentSearch": "Try a different search term.", + "@tryDifferentSearch": { + "description": "Hint text when note search returns no results." + }, + "loadingNotes": "Loading notes...", + "@loadingNotes": { + "description": "Progress message while fetching notes list." + }, + "loadingNote": "Loading note...", + "@loadingNote": { + "description": "Progress message while fetching a single note." + }, + "failedToLoadNotes": "Failed to load notes", + "@failedToLoadNotes": { + "description": "Error message when notes list cannot be retrieved." + }, + "deleteNoteTitle": "Delete Note", + "@deleteNoteTitle": { + "description": "Dialog title for confirming note deletion." + }, + "deleteNoteMessage": "Delete \"{title}\"? This cannot be undone.", + "@deleteNoteMessage": { + "description": "Confirmation message for deleting a note.", + "placeholders": { + "title": { + "type": "String", + "example": "My Note" + } + } + }, + "noteTitle": "Note title", + "@noteTitle": { + "description": "Hint text for note title input field." + }, + "writeNote": "Write something...", + "@writeNote": { + "description": "Hint text for note content input field." + }, + "noteSaved": "Note saved", + "@noteSaved": { + "description": "Confirmation message when note is saved." + }, + "saving": "Saving...", + "@saving": { + "description": "Status text while saving." + }, + "saved": "Saved", + "@saved": { + "description": "Status text when content is saved." + }, + "unsavedChanges": "Unsaved changes", + "@unsavedChanges": { + "description": "Status text when there are unsaved changes." + }, + "noteCopiedToClipboard": "Note copied to clipboard", + "@noteCopiedToClipboard": { + "description": "Confirmation message when note content is copied to clipboard." + }, + "generateTitle": "Generate Title", + "@generateTitle": { + "description": "Tooltip for generating a title with AI." + }, + "generatingTitle": "Generating title...", + "@generatingTitle": { + "description": "Placeholder while AI generates a title." + }, + "noContentToGenerateTitle": "Add some content first to generate a title", + "@noContentToGenerateTitle": { + "description": "Error when trying to generate title with empty content." + }, + "failedToGenerateTitle": "Failed to generate title", + "@failedToGenerateTitle": { + "description": "Error message when title generation fails." + }, + "noContentToEnhance": "Add some content first to enhance", + "@noContentToEnhance": { + "description": "Error when trying to enhance empty content." + }, + "noteEnhanced": "Note enhanced with AI", + "@noteEnhanced": { + "description": "Success message after AI enhancement." + }, + "failedToEnhanceNote": "Failed to enhance note", + "@failedToEnhanceNote": { + "description": "Error message when enhancement fails." + }, + "noModelSelected": "Please select a model first", + "@noModelSelected": { + "description": "Error message when no AI model is selected." + }, + "enhanceWithAI": "Enhance with AI", + "@enhanceWithAI": { + "description": "Tooltip for AI enhance button." + }, + "enhanceNote": "Enhance Note", + "@enhanceNote": { + "description": "Menu item to enhance note content." + }, + "startDictation": "Start dictation", + "@startDictation": { + "description": "Tooltip for dictation button." + }, + "stopRecording": "Stop recording", + "@stopRecording": { + "description": "Tooltip for stop recording button." + }, + "voiceInputUnavailable": "Voice input is not available", + "@voiceInputUnavailable": { + "description": "Error when voice input is not available." + }, + "failedToStartDictation": "Failed to start dictation", + "@failedToStartDictation": { + "description": "Error when starting dictation fails." + }, + "noteNotFound": "Note not found", + "@noteNotFound": { + "description": "Error message when a note cannot be found." + }, + "untitled": "Untitled", + "@untitled": { + "description": "Default title for notes without a title." + }, + "today": "Today", + "@today": { + "description": "Time range label for items from today." + }, + "yesterday": "Yesterday", + "@yesterday": { + "description": "Time range label for items from yesterday." + }, + "previous7Days": "Previous 7 Days", + "@previous7Days": { + "description": "Time range label for items from the last week." + }, + "previous30Days": "Previous 30 Days", + "@previous30Days": { + "description": "Time range label for items from the last month." + }, + "older": "Older", + "@older": { + "description": "Time range label for items older than 30 days." + }, + "tapToExpand": "Tap to expand", + "@tapToExpand": { + "description": "Hint text shown on collapsed sections." + }, + "byAuthor": "By {name}", + "@byAuthor": { + "description": "Attribution text showing the note author.", + "placeholders": { + "name": { + "type": "String", + "example": "John" + } + } + }, + "wordCount": "{count} words", + "@wordCount": { + "description": "Status bar text showing word count.", + "placeholders": { + "count": { + "type": "int", + "example": "150" + } + } + }, + "charCount": "{count} characters", + "@charCount": { + "description": "Status bar text showing character count.", + "placeholders": { + "count": { + "type": "int", + "example": "500" + } + } } } From a13a2de7d621f420bd005926887d791b0b17afcc Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 6 Dec 2025 19:48:33 +0530 Subject: [PATCH 2/2] feat(notes): Improve note editor change detection and UI refinements --- .../notes/views/note_editor_page.dart | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/lib/features/notes/views/note_editor_page.dart b/lib/features/notes/views/note_editor_page.dart index aadb808..ebb1f3e 100644 --- a/lib/features/notes/views/note_editor_page.dart +++ b/lib/features/notes/views/note_editor_page.dart @@ -117,10 +117,21 @@ class _NoteEditorPageState extends ConsumerState { } void _onContentChanged() { - if (!_hasChanges && mounted) { - setState(() => _hasChanges = true); + if (!mounted || _isLoading) return; + + // Check if content actually changed from the saved note + final titleChanged = _note != null && _titleController.text != _note!.title; + final contentChanged = + _note != null && _contentController.text != _note!.markdownContent; + final hasRealChanges = titleChanged || contentChanged; + + if (hasRealChanges != _hasChanges) { + setState(() => _hasChanges = hasRealChanges); + } + + if (hasRealChanges) { + _debounceSave(); } - _debounceSave(); } void _debounceSave() { @@ -378,9 +389,7 @@ class _NoteEditorPageState extends ConsumerState { } Future _startDictation() async { - _voiceService ??= VoiceInputService( - api: ref.read(apiServiceProvider), - ); + _voiceService ??= VoiceInputService(api: ref.read(apiServiceProvider)); try { final ok = await _voiceService!.initialize(); @@ -474,10 +483,12 @@ class _NoteEditorPageState extends ConsumerState { canPop: !_hasChanges, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; // Already popped, nothing to do + // Capture navigator before async gap + final navigator = Navigator.of(context); // Save changes before allowing pop await _saveNote(showFeedback: false); if (!mounted) return; - Navigator.of(context).pop(); + navigator.pop(); }, child: ErrorBoundary( child: Scaffold( @@ -572,7 +583,9 @@ class _NoteEditorPageState extends ConsumerState { // Generate title button - aligned with other header icons AnimatedOpacity( - opacity: _titleFocusNode.hasFocus && !_isGeneratingTitle ? 1.0 : 0.0, + opacity: _titleFocusNode.hasFocus && !_isGeneratingTitle + ? 1.0 + : 0.0, duration: const Duration(milliseconds: 150), child: IgnorePointer( ignoring: !_titleFocusNode.hasFocus || _isGeneratingTitle, @@ -846,11 +859,11 @@ class _NoteEditorPageState extends ConsumerState { context, icon: _isRecording ? (Platform.isIOS - ? CupertinoIcons.stop_fill - : Icons.stop_rounded) + ? CupertinoIcons.stop_fill + : Icons.stop_rounded) : (Platform.isIOS - ? CupertinoIcons.mic_fill - : Icons.mic_rounded), + ? CupertinoIcons.mic_fill + : Icons.mic_rounded), color: _isRecording ? theme.error : null, isLoading: false, tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation, @@ -909,11 +922,7 @@ class _NoteEditorPageState extends ConsumerState { ), ), ) - : Icon( - icon, - color: color ?? theme.iconPrimary, - size: IconSize.lg, - ), + : Icon(icon, color: color ?? theme.iconPrimary, size: IconSize.lg), ); if (showMenu) { @@ -932,22 +941,22 @@ class _NoteEditorPageState extends ConsumerState { } }, itemBuilder: (context) => [ - PopupMenuItem( - value: 'enhance', - child: Row( - children: [ - Icon( - Platform.isIOS - ? CupertinoIcons.sparkles - : Icons.auto_fix_high_rounded, - color: theme.buttonPrimary, - size: IconSize.md, - ), - const SizedBox(width: Spacing.sm), - Text(l10n.enhanceNote), - ], + PopupMenuItem( + value: 'enhance', + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.sparkles + : Icons.auto_fix_high_rounded, + color: theme.buttonPrimary, + size: IconSize.md, ), - ), + const SizedBox(width: Spacing.sm), + Text(l10n.enhanceNote), + ], + ), + ), PopupMenuItem( value: 'title', child: Row(