From 692a962acff5045ae8e8a045b41efb939f0a112d Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 5 Jun 2024 16:50:56 +0200 Subject: [PATCH] implemented chat archive --- app/lib/core/routing/routes.dart | 44 ++++- app/lib/core/routing/routes.g.dart | 64 ++++++- .../data/repositories/chat_archive_file.dart | 61 ++++++ .../repositories/chat_archive_file.g.dart | 28 +++ .../domain/entities/chat_entity.dart | 23 +++ .../domain/repositories/chat_archive.dart | 72 +++++++ .../domain/repositories/chat_archive.g.dart | 176 ++++++++++++++++++ .../presentation/screens/detail.dart | 138 ++++++++++++++ .../presentation/screens/list.dart | 60 ++++++ .../chat_archive/utils/markdown_to_text.dart | 9 + app/lib/features/kagi/data/services/chat.dart | 32 ++++ .../features/kagi/data/services/chat.g.dart | 26 +++ .../presentation/screens/browser.dart | 9 +- .../repositories/settings_repository.g.dart | 2 +- .../controllers/save_settings.dart | 2 +- .../controllers/save_settings.g.dart | 2 +- .../presentation/widgets/web_view.dart | 45 ++++- app/pubspec.lock | 8 +- app/pubspec.yaml | 4 + 19 files changed, 775 insertions(+), 30 deletions(-) create mode 100644 app/lib/features/chat_archive/data/repositories/chat_archive_file.dart create mode 100644 app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart create mode 100644 app/lib/features/chat_archive/domain/entities/chat_entity.dart create mode 100644 app/lib/features/chat_archive/domain/repositories/chat_archive.dart create mode 100644 app/lib/features/chat_archive/domain/repositories/chat_archive.g.dart create mode 100644 app/lib/features/chat_archive/presentation/screens/detail.dart create mode 100644 app/lib/features/chat_archive/presentation/screens/list.dart create mode 100644 app/lib/features/chat_archive/utils/markdown_to_text.dart create mode 100644 app/lib/features/kagi/data/services/chat.dart create mode 100644 app/lib/features/kagi/data/services/chat.g.dart diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 0d27b805..db436424 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -1,5 +1,7 @@ import 'package:bang_navigator/core/routing/dialog_page.dart'; import 'package:bang_navigator/features/about/presentation/screens/about.dart'; +import 'package:bang_navigator/features/chat_archive/presentation/screens/detail.dart'; +import 'package:bang_navigator/features/chat_archive/presentation/screens/list.dart'; import 'package:bang_navigator/features/search_browser/presentation/screens/browser.dart'; import 'package:bang_navigator/features/settings/presentation/screens/settings.dart'; import 'package:flutter/material.dart'; @@ -15,10 +17,6 @@ part 'routes.g.dart'; name: 'AboutRoute', path: 'about', ), - TypedGoRoute( - name: 'SettingsRoute', - path: 'settings', - ), ], ) class KagiRoute extends GoRouteData { @@ -30,6 +28,17 @@ class KagiRoute extends GoRouteData { } } +class AboutRoute extends GoRouteData { + @override + Page buildPage(BuildContext context, GoRouterState state) { + return DialogPage(builder: (_) => const AboutDialogScreen()); + } +} + +@TypedGoRoute( + name: 'SettingsRoute', + path: '/settings', +) class SettingsRoute extends GoRouteData { @override Widget build(BuildContext context, GoRouterState state) { @@ -37,9 +46,30 @@ class SettingsRoute extends GoRouteData { } } -class AboutRoute extends GoRouteData { +@TypedGoRoute( + name: 'ChatArchiveListRoute', + path: '/chat_archive', + routes: [ + TypedGoRoute( + name: 'ChatArchiveDetailRoute', + path: 'detail/:fileName', + ), + ], +) +class ChatArchiveListRoute extends GoRouteData { @override - Page buildPage(BuildContext context, GoRouterState state) { - return DialogPage(builder: (_) => const AboutDialogScreen()); + Widget build(BuildContext context, GoRouterState state) { + return const ChatArchiveListScreen(); + } +} + +class ChatArchiveDetailRoute extends GoRouteData { + final String fileName; + + const ChatArchiveDetailRoute({required this.fileName}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return ChatArchiveDetailScreen(fileName); } } diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 74562a2d..6daf3c16 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -8,6 +8,8 @@ part of 'routes.dart'; List get $appRoutes => [ $kagiRoute, + $settingsRoute, + $chatArchiveListRoute, ]; RouteBase get $kagiRoute => GoRouteData.$route( @@ -20,11 +22,6 @@ RouteBase get $kagiRoute => GoRouteData.$route( name: 'AboutRoute', factory: $AboutRouteExtension._fromState, ), - GoRouteData.$route( - path: 'settings', - name: 'SettingsRoute', - factory: $SettingsRouteExtension._fromState, - ), ], ); @@ -62,6 +59,12 @@ extension $AboutRouteExtension on AboutRoute { void replace(BuildContext context) => context.replace(location); } +RouteBase get $settingsRoute => GoRouteData.$route( + path: '/settings', + name: 'SettingsRoute', + factory: $SettingsRouteExtension._fromState, + ); + extension $SettingsRouteExtension on SettingsRoute { static SettingsRoute _fromState(GoRouterState state) => SettingsRoute(); @@ -78,3 +81,54 @@ extension $SettingsRouteExtension on SettingsRoute { void replace(BuildContext context) => context.replace(location); } + +RouteBase get $chatArchiveListRoute => GoRouteData.$route( + path: '/chat_archive', + name: 'ChatArchiveListRoute', + factory: $ChatArchiveListRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'detail/:fileName', + name: 'ChatArchiveDetailRoute', + factory: $ChatArchiveDetailRouteExtension._fromState, + ), + ], + ); + +extension $ChatArchiveListRouteExtension on ChatArchiveListRoute { + static ChatArchiveListRoute _fromState(GoRouterState state) => + ChatArchiveListRoute(); + + String get location => GoRouteData.$location( + '/chat_archive', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + +extension $ChatArchiveDetailRouteExtension on ChatArchiveDetailRoute { + static ChatArchiveDetailRoute _fromState(GoRouterState state) => + ChatArchiveDetailRoute( + fileName: state.pathParameters['fileName']!, + ); + + String get location => GoRouteData.$location( + '/chat_archive/detail/${Uri.encodeComponent(fileName)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/app/lib/features/chat_archive/data/repositories/chat_archive_file.dart b/app/lib/features/chat_archive/data/repositories/chat_archive_file.dart new file mode 100644 index 00000000..867d391f --- /dev/null +++ b/app/lib/features/chat_archive/data/repositories/chat_archive_file.dart @@ -0,0 +1,61 @@ +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:universal_io/io.dart'; +import 'package:watcher/watcher.dart'; + +part 'chat_archive_file.g.dart'; + +@Riverpod() +class ChatArchiveFileRepository extends _$ChatArchiveFileRepository { + final Future _archiveDirectoryFuture; + + ChatArchiveFileRepository() + : _archiveDirectoryFuture = + path_provider.getApplicationDocumentsDirectory().then( + (documentDirectory) => Directory( + path.join(documentDirectory.path, 'archive', 'chat'), + ).create(recursive: true), + ); + + Future> list() { + return _archiveDirectoryFuture.then((value) => value.list().toList()); + } + + Future write(String fileName, String contents) async { + final directory = await _archiveDirectoryFuture; + final file = File(path.join(directory.path, fileName)); + + await file.writeAsString(contents, flush: true); + } + + Future read(String fileName) async { + final directory = await _archiveDirectoryFuture; + final file = File(path.join(directory.path, fileName)); + + if (!await file.exists()) { + return null; + } + + return file.readAsString(); + } + + Future delete(String fileName) async { + final directory = await _archiveDirectoryFuture; + final file = File(path.join(directory.path, fileName)); + + if (await file.exists()) { + await file.delete(); + } + } + + @override + Raw> build() async* { + final watcher = DirectoryWatcher( + await _archiveDirectoryFuture + .then((archiveDirectory) => archiveDirectory.absolute.path), + ); + + yield* watcher.events; + } +} diff --git a/app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart b/app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart new file mode 100644 index 00000000..089e4e4c --- /dev/null +++ b/app/lib/features/chat_archive/data/repositories/chat_archive_file.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_archive_file.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatArchiveFileRepositoryHash() => + r'50268506cf0c23ba1724ece6c9f44cb87be6299f'; + +/// See also [ChatArchiveFileRepository]. +@ProviderFor(ChatArchiveFileRepository) +final chatArchiveFileRepositoryProvider = AutoDisposeNotifierProvider< + ChatArchiveFileRepository, Raw>>.internal( + ChatArchiveFileRepository.new, + name: r'chatArchiveFileRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatArchiveFileRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ChatArchiveFileRepository + = AutoDisposeNotifier>>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/domain/entities/chat_entity.dart b/app/lib/features/chat_archive/domain/entities/chat_entity.dart new file mode 100644 index 00000000..58d0ca6a --- /dev/null +++ b/app/lib/features/chat_archive/domain/entities/chat_entity.dart @@ -0,0 +1,23 @@ +class ChatEntity { + static final _namePattern = RegExp(r"^(.*?) - (.*?)\.md$"); + + final String fileName; + + final String? name; + final DateTime? dateTime; + + ChatEntity._(this.fileName, {this.name, this.dateTime}); + + factory ChatEntity.fromFileName(String fileName) { + final match = _namePattern.firstMatch(fileName); + + return ChatEntity._( + fileName, + name: match?.group(1), + dateTime: (match != null) ? DateTime.tryParse(match.group(2)!) : null, + ); + } + + @override + String toString() => name ?? fileName; +} diff --git a/app/lib/features/chat_archive/domain/repositories/chat_archive.dart b/app/lib/features/chat_archive/domain/repositories/chat_archive.dart new file mode 100644 index 00000000..0e1a1fc0 --- /dev/null +++ b/app/lib/features/chat_archive/domain/repositories/chat_archive.dart @@ -0,0 +1,72 @@ +import 'package:bang_navigator/features/chat_archive/data/repositories/chat_archive_file.dart'; +import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; +import 'package:bang_navigator/features/kagi/data/services/chat.dart'; +import 'package:exceptions/exceptions.dart'; +import 'package:path/path.dart' as path; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'chat_archive.g.dart'; + +@Riverpod() +class ChatArchiveRepository extends _$ChatArchiveRepository { + Future> _listArchivedChats() async { + final files = + await ref.read(chatArchiveFileRepositoryProvider.notifier).list(); + + return files + .where((file) => path.extension(file.path) == '.md') + .map((file) => ChatEntity.fromFileName(path.basename(file.path))) + .toList(); + } + + Future> archiveChat(String fileName, Uri url) async { + final contentsResult = + await ref.read(kagiChatServiceProvider.notifier).downloadChat(url); + + return contentsResult.flatMapAsync( + (contents) => ref + .read(chatArchiveFileRepositoryProvider.notifier) + .write(fileName, contents), + ); + } + + Future> readChat(String fileName) async { + final contentsResult = await Result.fromAsync( + () => ref.read(chatArchiveFileRepositoryProvider.notifier).read(fileName), + ); + + return contentsResult.fold( + (value) => (value == null) + ? Result.failure( + ErrorMessage( + source: 'Chat Archive', + message: 'Chat $fileName not found', + ), + ) + : Result.success(value), + onFailure: Result.failure, + ); + } + + @override + Stream> build() async* { + final fileRepository = ref.watch(chatArchiveFileRepositoryProvider); + + yield* ConcatStream([ + _listArchivedChats().asStream(), + fileRepository.asyncMap( + (_) => _listArchivedChats(), + ), + ]); + } +} + +@Riverpod() +Future readArchivedChat( + ReadArchivedChatRef ref, String fileName) async { + final result = + await ref.read(chatArchiveRepositoryProvider.notifier).readChat(fileName); + + return result.value; +} diff --git a/app/lib/features/chat_archive/domain/repositories/chat_archive.g.dart b/app/lib/features/chat_archive/domain/repositories/chat_archive.g.dart new file mode 100644 index 00000000..7bfa3142 --- /dev/null +++ b/app/lib/features/chat_archive/domain/repositories/chat_archive.g.dart @@ -0,0 +1,176 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_archive.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$readArchivedChatHash() => r'e7d646a0ea22fe24d96e97f86c1d46fe83818508'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [readArchivedChat]. +@ProviderFor(readArchivedChat) +const readArchivedChatProvider = ReadArchivedChatFamily(); + +/// See also [readArchivedChat]. +class ReadArchivedChatFamily extends Family> { + /// See also [readArchivedChat]. + const ReadArchivedChatFamily(); + + /// See also [readArchivedChat]. + ReadArchivedChatProvider call( + String fileName, + ) { + return ReadArchivedChatProvider( + fileName, + ); + } + + @override + ReadArchivedChatProvider getProviderOverride( + covariant ReadArchivedChatProvider provider, + ) { + return call( + provider.fileName, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'readArchivedChatProvider'; +} + +/// See also [readArchivedChat]. +class ReadArchivedChatProvider extends AutoDisposeFutureProvider { + /// See also [readArchivedChat]. + ReadArchivedChatProvider( + String fileName, + ) : this._internal( + (ref) => readArchivedChat( + ref as ReadArchivedChatRef, + fileName, + ), + from: readArchivedChatProvider, + name: r'readArchivedChatProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$readArchivedChatHash, + dependencies: ReadArchivedChatFamily._dependencies, + allTransitiveDependencies: + ReadArchivedChatFamily._allTransitiveDependencies, + fileName: fileName, + ); + + ReadArchivedChatProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.fileName, + }) : super.internal(); + + final String fileName; + + @override + Override overrideWith( + FutureOr Function(ReadArchivedChatRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ReadArchivedChatProvider._internal( + (ref) => create(ref as ReadArchivedChatRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + fileName: fileName, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _ReadArchivedChatProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ReadArchivedChatProvider && other.fileName == fileName; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, fileName.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin ReadArchivedChatRef on AutoDisposeFutureProviderRef { + /// The parameter `fileName` of this provider. + String get fileName; +} + +class _ReadArchivedChatProviderElement + extends AutoDisposeFutureProviderElement with ReadArchivedChatRef { + _ReadArchivedChatProviderElement(super.provider); + + @override + String get fileName => (origin as ReadArchivedChatProvider).fileName; +} + +String _$chatArchiveRepositoryHash() => + r'c53b0d2704953a205c12357642237e15e8ffd3b1'; + +/// See also [ChatArchiveRepository]. +@ProviderFor(ChatArchiveRepository) +final chatArchiveRepositoryProvider = AutoDisposeStreamNotifierProvider< + ChatArchiveRepository, List>.internal( + ChatArchiveRepository.new, + name: r'chatArchiveRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatArchiveRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ChatArchiveRepository = AutoDisposeStreamNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/chat_archive/presentation/screens/detail.dart b/app/lib/features/chat_archive/presentation/screens/detail.dart new file mode 100644 index 00000000..f97e3e16 --- /dev/null +++ b/app/lib/features/chat_archive/presentation/screens/detail.dart @@ -0,0 +1,138 @@ +import 'package:bang_navigator/core/routing/routes.dart'; +import 'package:bang_navigator/features/chat_archive/data/repositories/chat_archive_file.dart'; +import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/chat_archive.dart'; +import 'package:bang_navigator/features/chat_archive/utils/markdown_to_text.dart'; +import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; +import 'package:bang_navigator/features/web_view/presentation/controllers/switch_new_tab.dart'; +import 'package:bang_navigator/presentation/widgets/failure_widget.dart'; +import 'package:bang_navigator/utils/ui_helper.dart' as ui_helper; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class ChatArchiveDetailScreen extends HookConsumerWidget { + final String fileName; + + const ChatArchiveDetailScreen(this.fileName, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chatAsync = ref.watch(readArchivedChatProvider(fileName)); + + final entity = useMemoized(() => ChatEntity.fromFileName(fileName)); + + return Scaffold( + appBar: AppBar( + title: Text(entity.toString()), + actions: [ + MenuAnchor( + builder: (context, controller, child) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert), + ); + }, + menuChildren: [ + MenuItemButton( + onPressed: () async { + if (chatAsync.valueOrNull != null) { + await Clipboard.setData( + ClipboardData( + text: await Future.microtask( + () => markdownToText(chatAsync.valueOrNull!), + ), + ), + ); + } + }, + leadingIcon: const Icon(MdiIcons.textLong), + child: const Text('Copy as plain text'), + ), + MenuItemButton( + onPressed: () async { + if (chatAsync.valueOrNull != null) { + await Clipboard.setData( + ClipboardData( + text: chatAsync.valueOrNull!, + ), + ); + } + }, + // ignore: deprecated_member_use + leadingIcon: const Icon(MdiIcons.languageMarkdown), + child: const Text('Copy as markdown'), + ), + const Divider(), + MenuItemButton( + onPressed: () async { + await ref + .read(chatArchiveFileRepositoryProvider.notifier) + .delete(fileName); + + if (context.mounted) { + context.pop(); + } + }, + leadingIcon: const Icon(Icons.delete), + child: const Text('Delete'), + ), + ], + ), + ], + ), + body: Skeletonizer( + enabled: chatAsync.isLoading, + child: chatAsync.when( + data: (data) => Markdown( + data: data, + selectable: true, + onTapLink: (text, href, title) async { + if (href != null) { + if (Uri.parse(href) case final Uri url) { + final launchExternal = ref + .read(settingsRepositoryProvider) + .valueOrNull + ?.launchUrlExternal ?? + false; + + if (launchExternal) { + await ui_helper.launchUrlFeedback(context, Uri.parse(href)); + } else { + await ref + .read(switchNewTabControllerProvider.notifier) + .add(url); + + if (context.mounted) { + context.go(KagiRoute().location); + } + } + } + } + }, + ), + error: (error, stackTrace) { + return FailureWidget( + title: error.toString(), + onRetry: () => ref.refresh(readArchivedChatProvider(fileName)), + ); + }, + loading: () => const Bone.multiText( + lines: 10, + ), + ), + ), + ); + } +} diff --git a/app/lib/features/chat_archive/presentation/screens/list.dart b/app/lib/features/chat_archive/presentation/screens/list.dart new file mode 100644 index 00000000..3510be0b --- /dev/null +++ b/app/lib/features/chat_archive/presentation/screens/list.dart @@ -0,0 +1,60 @@ +import 'package:bang_navigator/core/routing/routes.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/chat_archive.dart'; +import 'package:bang_navigator/presentation/widgets/failure_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class ChatArchiveListScreen extends HookConsumerWidget { + const ChatArchiveListScreen({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final chatsAsync = ref.watch(chatArchiveRepositoryProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Chat Archive')), + body: SafeArea( + child: Skeletonizer( + enabled: chatsAsync.isLoading, + child: chatsAsync.when( + data: (chats) { + return ListView.builder( + itemCount: chats.length, + itemBuilder: (context, index) { + final chat = chats[index]; + + return ListTile( + title: Text(chat.toString()), + subtitle: (chat.dateTime != null) + ? Text(chat.dateTime.toString()) + : null, + onTap: () async { + await context.push( + ChatArchiveDetailRoute(fileName: chat.fileName) + .location, + ); + }, + ); + }, + ); + }, + error: (error, stackTrace) { + return FailureWidget( + title: error.toString(), + onRetry: () => ref.refresh(chatArchiveRepositoryProvider), + ); + }, + loading: () => ListView.builder( + itemCount: 3, + itemBuilder: (context, index) => const ListTile( + title: Bone.text(), + subtitle: Bone.text(), + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/chat_archive/utils/markdown_to_text.dart b/app/lib/features/chat_archive/utils/markdown_to_text.dart new file mode 100644 index 00000000..9a2bed31 --- /dev/null +++ b/app/lib/features/chat_archive/utils/markdown_to_text.dart @@ -0,0 +1,9 @@ +import 'package:html/parser.dart' as html_parser; +import 'package:markdown/markdown.dart' as md; + +String markdownToText(String markdown) { + final html = md.markdownToHtml(markdown); + final document = html_parser.parse(html); + + return document.body?.text ?? ''; +} diff --git a/app/lib/features/kagi/data/services/chat.dart b/app/lib/features/kagi/data/services/chat.dart new file mode 100644 index 00000000..b35a78d0 --- /dev/null +++ b/app/lib/features/kagi/data/services/chat.dart @@ -0,0 +1,32 @@ +import 'package:bang_navigator/core/http_error_handler.dart'; +import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; +import 'package:exceptions/exceptions.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'chat.g.dart'; + +@Riverpod(keepAlive: true) +class KagiChatService extends _$KagiChatService { + late http.Client _client; + + @override + void build() { + _client = http.Client(); + } + + Future> downloadChat(Uri url) { + final kagiSession = + ref.read(settingsRepositoryProvider).valueOrNull?.kagiSession; + + return Result.fromAsync( + () async { + final response = await _client + .get(url.replace(queryParameters: {'token': kagiSession})); + + return response.body; + }, + exceptionHandler: handleHttpError, + ); + } +} diff --git a/app/lib/features/kagi/data/services/chat.g.dart b/app/lib/features/kagi/data/services/chat.g.dart new file mode 100644 index 00000000..09d10f6c --- /dev/null +++ b/app/lib/features/kagi/data/services/chat.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$kagiChatServiceHash() => r'80ead208a1d7ca0da98bd20f11c99efd0d682c8f'; + +/// See also [KagiChatService]. +@ProviderFor(KagiChatService) +final kagiChatServiceProvider = + NotifierProvider.internal( + KagiChatService.new, + name: r'kagiChatServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$kagiChatServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$KagiChatService = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/search_browser/presentation/screens/browser.dart b/app/lib/features/search_browser/presentation/screens/browser.dart index 78786a29..63baa20f 100644 --- a/app/lib/features/search_browser/presentation/screens/browser.dart +++ b/app/lib/features/search_browser/presentation/screens/browser.dart @@ -25,7 +25,6 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; class KagiScreen extends HookConsumerWidget { const KagiScreen({super.key}); @@ -208,6 +207,14 @@ class KagiScreen extends HookConsumerWidget { child: const Text('Summarizer'), ), const Divider(), + MenuItemButton( + onPressed: () async { + await context.push(ChatArchiveListRoute().location); + }, + leadingIcon: const Icon(MdiIcons.archive), + child: const Text('Chat Archive'), + ), + const Divider(), MenuItemButton( onPressed: () async { await context.push(AboutRoute().location); diff --git a/app/lib/features/settings/data/repositories/settings_repository.g.dart b/app/lib/features/settings/data/repositories/settings_repository.g.dart index 22289050..3e947766 100644 --- a/app/lib/features/settings/data/repositories/settings_repository.g.dart +++ b/app/lib/features/settings/data/repositories/settings_repository.g.dart @@ -7,7 +7,7 @@ part of 'settings_repository.dart'; // ************************************************************************** String _$settingsRepositoryHash() => - r'1e1dc6c76bdcaf742335bf8e11a584093ec13f33'; + r'2b715f29915e541cec5c7c0993c8099944989f48'; /// See also [SettingsRepository]. @ProviderFor(SettingsRepository) diff --git a/app/lib/features/settings/presentation/controllers/save_settings.dart b/app/lib/features/settings/presentation/controllers/save_settings.dart index b9536403..4fe85e95 100644 --- a/app/lib/features/settings/presentation/controllers/save_settings.dart +++ b/app/lib/features/settings/presentation/controllers/save_settings.dart @@ -3,7 +3,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'save_settings.g.dart'; -@riverpod +@Riverpod() class SaveSettingsController extends _$SaveSettingsController { @override FutureOr build() {} diff --git a/app/lib/features/settings/presentation/controllers/save_settings.g.dart b/app/lib/features/settings/presentation/controllers/save_settings.g.dart index d8d18477..4fce547d 100644 --- a/app/lib/features/settings/presentation/controllers/save_settings.g.dart +++ b/app/lib/features/settings/presentation/controllers/save_settings.g.dart @@ -7,7 +7,7 @@ part of 'save_settings.dart'; // ************************************************************************** String _$saveSettingsControllerHash() => - r'861664dc6e526737adff645597b141c2ba9d97c1'; + r'f03cbb1a3a04f974560f5b6f0312fcc54ec25c54'; /// See also [SaveSettingsController]. @ProviderFor(SaveSettingsController) diff --git a/app/lib/features/web_view/presentation/widgets/web_view.dart b/app/lib/features/web_view/presentation/widgets/web_view.dart index e348f2e8..9e27eade 100644 --- a/app/lib/features/web_view/presentation/widgets/web_view.dart +++ b/app/lib/features/web_view/presentation/widgets/web_view.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:bang_navigator/core/logger.dart'; +import 'package:bang_navigator/features/chat_archive/domain/entities/chat_entity.dart'; +import 'package:bang_navigator/features/chat_archive/domain/repositories/chat_archive.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dart'; import 'package:bang_navigator/features/search_browser/domain/providers.dart'; @@ -10,6 +12,7 @@ import 'package:bang_navigator/features/settings/data/repositories/settings_repo import 'package:bang_navigator/features/web_view/domain/entities/web_view_page.dart'; import 'package:bang_navigator/features/web_view/presentation/controllers/switch_new_tab.dart'; import 'package:bang_navigator/features/web_view/presentation/widgets/web_page_dialog.dart'; +import 'package:bang_navigator/features/web_view/utils/download_helper.dart'; import 'package:bang_navigator/features/web_view/utils/favicon_helper.dart'; import 'package:bang_navigator/utils/platform_util.dart' as platform_util; import 'package:bang_navigator/utils/ui_helper.dart' as ui_helper; @@ -328,18 +331,40 @@ class _WebViewState extends ConsumerState { onTitleChanged: (controller, title) { widget.updatePage((page) => page.copyWith.title(title)); }, + onDownloadStartRequest: (controller, downloadStartRequest) async { + final fileName = getDispositionFileName( + downloadStartRequest.contentDisposition!, + ); - // onDownloadStartRequest: (controller, downloadStartRequest) { - // final regex = RegExp( - // r"filename\*=UTF-8''([\w%\-\.]+)(?:; ?|$)", - // caseSensitive: false, - // ); + if (fileName != null) { + final entity = ChatEntity.fromFileName(fileName); + if (entity.name != null) { + final fileWrite = await ref + .read(chatArchiveRepositoryProvider.notifier) + .archiveChat(fileName, downloadStartRequest.url); - // final math = - // regex.firstMatch(downloadStartRequest.contentDisposition!); - - // print(math); - // }, + fileWrite.map( + onSuccess: (_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.green, + content: Text( + 'Conversation "$entity" saved successfully!', + style: const TextStyle(color: Colors.white), + ), + ), + ); + }, + onFailure: (errorMessage) { + ui_helper.showErrorMessage( + context, + errorMessage.toString(), + ); + }, + ); + } + } + }, ), HookBuilder( builder: (context) { diff --git a/app/pubspec.lock b/app/pubspec.lock index 0813ff67..d4ebf07e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -676,7 +676,7 @@ packages: source: hosted version: "1.2.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 @@ -740,7 +740,7 @@ packages: source: hosted version: "3.0.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -748,7 +748,7 @@ packages: source: hosted version: "1.9.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 @@ -1217,7 +1217,7 @@ packages: source: hosted version: "14.2.3" watcher: - dependency: transitive + dependency: "direct main" description: name: watcher sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 2af238e7..dc72c30b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -29,8 +29,11 @@ dependencies: html: ^0.15.4 http: ^1.2.1 logger: ^2.2.0 + markdown: ^7.2.2 mime: ^1.0.5 package_info_plus: ^8.0.0 + path: ^1.9.0 + path_provider: ^2.1.3 riverpod: ^2.5.1 riverpod_annotation: ^2.3.5 rxdart: ^0.27.7 @@ -42,6 +45,7 @@ dependencies: universal_io: ^2.2.2 uri_to_file: ^1.0.0 url_launcher: ^6.2.5 + watcher: ^1.1.0 dev_dependencies: build_runner: ^2.4.9