diff --git a/README.md b/README.md index 456433c..01ff86f 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,71 @@ # Augor -> Drilling beneath surface sentiment to extract trading signals from financial news. +> Using embeddings to find meaningful patterns in financial news ## What is this? -Augor uses Large Language Models to interpret business events and generate trading signals. Instead of just counting positive/negative words, it understands what events *mean* for markets. +Augor is a Flutter desktop application that aggregates financial news from multiple RSS sources and uses OpenAI embeddings to cluster related articles about the same business events. Instead of manually tracking dozens of news sources, Augor automatically groups stories so you can see what's actually happening in markets. -Traditional sentiment analysis: "This news mentions 'partnership' → positive" -Augor: "This partnership expands market access but dilutes margins → mixed signal" +## Current Features -## Why? +**RSS Feed Aggregation** +- Supports RSS and Atom feeds +- Pre-configured with 20+ major business news sources (Reuters, Bloomberg, WSJ, etc) +- Add custom feeds through the settings interface +- Enable/disable feeds individually -Traditional NLP methods are limited: -- Can't understand context or nuance -- Miss complex implications of business events -- Struggle with negation and sarcasm +**AI-Powered Processing** +- Generates embeddings using OpenAI's text-embedding-3-small model +- Filters articles for business relevance using keyword similarity +- Groups related articles about the same event using cosine similarity +- Exports results as JSON for further analisis -LLMs can reason about *why* news matters, not just classify it as positive/negative. +**Settings Management** +- Configure OpenAI API key +- Manage RSS feed sources +- Set custom storage location for output files -## What it does +## Technical Stack -1. Aggregates news from multiple sources -2. Interprets business events (mergers, partnerships, product launches) -3. Generates trading signals based on reasoned analysis -4. Backtests performance against traditional approaches +- **Flutter** - Cross-platform desktop app (macOS, Windows, Linux) +- **shadcn_flutter** - UI component library +- **OpenAI API** - Text embeddings for semantic similarity +- **Provider** - State managment +- **go_router** - Navigation + +## Setup + +1. Clone the repository +2. Install dependencies: + ```bash + flutter pub get + ``` +3. Create a `.env` file with your OpenAI API key (or configure it in the app settings) +4. Run the app: + ```bash + flutter run + ``` + +## How it works + +1. **Aggregate** - Fetches articles from all enabled RSS feeds +2. **Embed** - Generates vector embeddings for article titles and descriptions +3. **Filter** - Removes articles not relevant to business/finance using keyword matching +4. **Cluster** - Groups similar articles (cosine similarity >= 0.7) to identify events +5. **Export** - Saves results to JSON files in your configured storage location + +## Output Files + +The app generates several JSON files in your storage directory: + +- `aggregated_feed.json` - All fetched articles +- `enriched_aggregated_feed.json` - Articles with embeddings +- `relevant_aggregated_feed.json` - Filtered relevant articles +- `grouped_relevant_aggregated_feed.json` - Articles clustered by event +- `readable_*.json` - Human-readable versions without embeddings ## Author -Benjamin Watt -Supervisor: Panagiotis Kanellopoulos +Benjamin Watt +Supervisor: Panagiotis Kanellopoulos University of Essex \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 532dc5c..25c46bb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,17 @@ import 'package:capstone_project/pages/home.dart'; import 'package:capstone_project/pages/settings.dart'; +import 'package:capstone_project/providers/settings.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -void main() { +SettingsProvider _settingsProvider = SettingsProvider(); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await _settingsProvider.load(); + runApp(const MyApp()); } @@ -13,11 +21,16 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return ShadcnApp.router( - theme: ThemeData( - colorScheme: ColorSchemes.darkRose + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _settingsProvider), + ], + child: ShadcnApp.router( + theme: ThemeData( + colorScheme: ColorSchemes.darkRose + ), + routerConfig: _routerConfig, ), - routerConfig: _routerConfig, ); } } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 60be394..2b7042e 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -1,4 +1,9 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:capstone_project/providers/settings.dart'; +import 'package:capstone_project/utils/agrigator.dart'; import 'package:capstone_project/widgets/navbar.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -22,12 +27,108 @@ class HomePage extends StatelessWidget { currentPage: "home", ) ], - child: Column( - children: [ + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + + Text( + "Nothing here is final yet!", + ).h1, + + Gap(16), + + Button.primary( + onPressed: () async { + print("Aggregating feeds..."); + SettingsProvider settings = SettingsProvider.of(context); + + // Fetch all feeds + List feedUris = settings.feeds.map((feed) => Uri.parse(feed.url)).toList(); + List aggregatedItems = await fetchFeeds(feedUris); + + // Save it to a file before generating embeddings + String agregatedJson = JsonEncoder.withIndent(' ').convert(aggregatedItems); + File agregatedJsonFile = File("${settings.applicationStorageLocation}/aggregated_feed.json"); + await agregatedJsonFile.writeAsString(agregatedJson); + print("Aggregated feed saved to ${agregatedJsonFile.path}"); + + // Generate embeddings for all items + print("Generating embeddings for ${aggregatedItems.length} items..."); + List enrichedItems = [...aggregatedItems]; + await generateEmbeddings(enrichedItems, settings.openAIApiKey); + + // Save it to a file in the application storage location + String enrichedJson = JsonEncoder.withIndent(' ').convert(enrichedItems); + final file = File("${settings.applicationStorageLocation}/enriched_aggregated_feed.json"); + await file.writeAsString(enrichedJson); + print("Enriched aggregated feed saved to ${file.path}"); + + // Filter out irrelevant items + print("Filtering relevant items..."); + await generateKeywordEmbeddings(settings.openAIApiKey); + List relevantItems = [...aggregatedItems]..removeWhere((item) => !isFeedItemRelevant(item)); + String relevantJson = JsonEncoder.withIndent(' ').convert(relevantItems); + final fileRelevant = File("${settings.applicationStorageLocation}/relevant_aggregated_feed.json"); + await fileRelevant.writeAsString(relevantJson); + print("Cut down from ${aggregatedItems.length} to ${relevantItems.length} relevant items."); + print("Relevant aggregated feed saved to ${fileRelevant.path}"); + + // For human readability, save a version without embeddings + List readableItems = relevantItems.map((item) { + return FeedItem( + title: item.title, + link: item.link, + description: item.description, + ); + }).toList(); + + String readableJson = JsonEncoder.withIndent(' ').convert(readableItems); + final fileReadable = File("${settings.applicationStorageLocation}/readable_relevant_aggregated_feed.json"); + await fileReadable.writeAsString(readableJson); + print("Readable relevant aggregated feed saved to ${fileReadable.path}"); + + // Group by event + print("Grouping feed items by event..."); + List> groupedItems = groupFeedItemsByEvent(relevantItems); + List>> groupedItemsJson = groupedItems.map((group) { + return group.map((item) => item.toJson()).toList(); + }).toList(); + String groupedJson = JsonEncoder.withIndent(' ').convert(groupedItemsJson); + final fileGrouped = File("${settings.applicationStorageLocation}/grouped_relevant_aggregated_feed.json"); + await fileGrouped.writeAsString(groupedJson); + print("Grouped relevant aggregated feed saved to ${fileGrouped.path}"); + + // For human readability, save a version without embeddings + List> readableGroupedItems = groupedItems.map((group) { + return group.map((item) { + return FeedItem( + title: item.title, + link: item.link, + description: item.description, + ); + }).toList(); + }).toList(); + // Sort groups by size descending + readableGroupedItems.sort((a, b) => b.length.compareTo(a.length)); + List>> readableGroupedItemsJson = readableGroupedItems.map((group) { + return group.map((item) => item.toJson()).toList(); + }).toList(); + String readableGroupedJson = JsonEncoder.withIndent(' ').convert(readableGroupedItemsJson); + final fileReadableGrouped = File("${settings.applicationStorageLocation}/readable_grouped_relevant_aggregated_feed.json"); + await fileReadableGrouped.writeAsString(readableGroupedJson); + print("Readable grouped relevant aggregated feed saved to ${fileReadableGrouped.path}"); + + }, + child: Text( + "Aggregate your feeds" + ), + ) - ], + ], + ), ), ); } diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 265f07b..cd64a13 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -1,18 +1,65 @@ +import 'package:capstone_project/providers/settings.dart'; import 'package:capstone_project/widgets/navbar.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:go_router/go_router.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulWidget { static GoRoute route = GoRoute( path: "/settings", builder: (context, state) => SettingsPage() ); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + + bool _isLoading = true; + + String apiKey = ""; + List feeds = []; + String appStorageLocation = ""; + + @override + void initState() { + // TODO: implement initState + super.initState(); + + loadPage(); + } + + void loadPage() async { + SettingsProvider settings = SettingsProvider.of(context); + + setState(() { + _isLoading = true; + }); + + apiKey = settings.openAIApiKey; + feeds = settings.feeds; + appStorageLocation = settings.applicationStorageLocation; + + setState(() { + _isLoading = false; + }); + } + @override Widget build(BuildContext context) { - // TODO: implement build + + if (_isLoading) { + return Scaffold( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + return Scaffold( headers: [ AppBar( @@ -24,58 +71,377 @@ class SettingsPage extends StatelessWidget { currentPage: "settings", ) ], - child: Column( - children: [ - - SizedBox( - width: double.infinity, - child: Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - - Text( - "API Key" - ).extraBold, - - const SizedBox(height: 16), - - TextField( - + child: Center( + child: SizedBox( + width: 600, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Text( + "Open AI" + ).h4, + + Gap(12), + + Text( + "API Key" + ).small.normal, + + Gap(8), + + TextField( + placeholder: Text( + "Enter your OpenAI API key" ), - + initialValue: apiKey.substring(0, 8) + "xxxxxx (Redacted for security)" , + onChanged: (value) { + SettingsProvider settings = SettingsProvider.of(context); + + settings.setOpenAIApiKey(value); + }, + ), + + Gap(8), + + if (apiKey.isEmpty) + DestructiveBadge( + child: Text( + "API key is required to use AI features." + ), + ), + + Gap(16), + + Divider(), + + Gap(16), + + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "News Feeds" + ).h4, + + Gap(4), + + Text( + "Manage your RSS news feeds." + ).muted.small, + ], + ), + ), + + Button.outline( + child: Text( + "Add Feed" + ), + onPressed: () { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AddFeedDialog() + ).then((value) { + loadPage(); + }); + }, + ) + ], + ), + + Gap(24), + + for (Feed feed in feeds) ...[ + + Row( + children: [ + Expanded( + child: Text( + feed.title.isNotEmpty ? feed.title : feed.url, + style: TextStyle( + decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough + ), + ), + ), + + Builder( + builder: (context2) { + return GhostButton( + density: ButtonDensity.dense, + child: Icon( + LucideIcons.ellipsis, + + ), + onPressed: () { + + + + showDropdown( + context: context2, + builder: (_) { + return DropdownMenu( + children: [ + MenuButton( + child: Text( + "Edit" + ), + trailing: Icon( + LucideIcons.filePen + ), + onPressed: (_) { + // Navigator.of(context).pop(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AddFeedDialog( + existingFeed: feed, + ) + ).then((value) { + loadPage(); + }); + + }, + ) + ], + ); + } + ); + }, + ); + } + ), + + Gap(4), + + Switch( + value: feed.enabled, + onChanged: (value) { + SettingsProvider settings = SettingsProvider.of(context); + + settings.updateFeed( + feed.id, + enabled: value + ); + + loadPage(); + }, + ), + ], + ), + + SelectableText( + feed.url, + style: TextStyle( + decoration: feed.enabled ? TextDecoration.none : TextDecoration.lineThrough + ), + ).muted.small, + + Gap(12), ], - ), + + Gap(12), + + Divider(), + + Gap(16), + + Text( + "Application Data" + ).h4, + + Gap(12), + + Text( + "Application Storage Location" + ).small.normal, + + Gap(8), + + Row( + children: [ + Expanded( + child: TextField( + placeholder: Text( + "Select a diretory using Browse" + ), + initialValue: appStorageLocation, + enabled: false, + ), + ), + + Gap(8), + + Button.outline( + child: Text( + "Browse" + ), + onPressed: () { + + FilePicker.platform.getDirectoryPath().then((selectedPath) { + if (selectedPath != null) { + SettingsProvider settings = SettingsProvider.of(context); + + settings.setApplicationStorageLocation(selectedPath); + + loadPage(); + + } + }); + + }, + ), + + Gap(8), + + IconButton.destructive( + icon: Icon( + LucideIcons.refreshCcw + ), + onPressed: () async { + SettingsProvider settings = SettingsProvider.of(context); + + settings.setApplicationStorageLocation((await getApplicationDocumentsDirectory()).path); + + loadPage(); + }, + ) + ], + ), + + ], + ).withMargin( + all: 10 ), ), + ), + ).withPadding(all: 24), + ); + } +} - const SizedBox(height: 16), +class AddFeedDialog extends StatelessWidget { - SizedBox( - width: double.infinity, - child: Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Feed? existingFeed; + + AddFeedDialog({super.key, this.existingFeed}); + + InputKey feedTitleKey = InputKey("feed_title"); + InputKey feedUrlKey = InputKey("feed_url"); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + existingFeed == null ? "Add Feed" : "Edit Feed" + ), + content: SizedBox( + width: 400, + child: Form( + onSubmit: (context, values) { + String title = values[feedTitleKey] ?? ""; + String url = values[feedUrlKey] ?? ""; + + SettingsProvider settings = SettingsProvider.of(context); + + if (existingFeed != null) { + settings.updateFeed( + existingFeed!.id, + title: title, + url: url + ); + } else { + settings.addFeed(Feed( + title: title, + url: url, + enabled: true + )); + } + + Navigator.of(context).pop(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + + Text( + "Add a custom RSS feed to your news sources." + ).muted, + + Gap(16), + + FormField( + key: feedTitleKey, + label: Text( + "Title" + ), + validator: ConditionalValidator((value) { + if (value is String) { + return value.trim().isNotEmpty; + } + return false; + }, message: "Title cannot be empty"), + child: TextField( + initialValue: existingFeed?.title, + ), + ), + + Gap(12), + + FormField( + key: feedUrlKey, + label: Text( + "Resource URL" + ), + validator: ConditionalValidator((value) { + if (value is String) { + return value.trim().isNotEmpty; + } + return false; + }, message: "URL cannot be empty"), + child: TextField( + initialValue: existingFeed?.url, + ) + ), + + Gap(24), + + Text( + "Only use valid RSS feed URLs. Preferably only use sites you trust, and specifically business and financial news sources." + ), + + Gap(24), + + Row( + mainAxisAlignment: MainAxisAlignment.end, children: [ - Text( - "Feeds" - ).extraBold, - - const SizedBox(height: 16), - - TextField( - + Button.outline( + child: Text( + "Cancel" + ), + onPressed: () { + Navigator.of(context).pop(); + }, ), - ], - ), - ), - ) + Gap(8), - ], - ).withMargin( - all: 10 + SubmitButton( + child: Text( + existingFeed != null ? "Update Feed" : "Add Feed" + ), + ), + ], + ) + ], + ), + ), ), ); } diff --git a/lib/providers/settings.dart b/lib/providers/settings.dart index 46acfea..5fa73d7 100644 --- a/lib/providers/settings.dart +++ b/lib/providers/settings.dart @@ -1,10 +1,235 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uid/uid.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; class SettingsProvider extends ChangeNotifier { + String _OPENAI_API_KEY = ""; + String get openAIApiKey => _OPENAI_API_KEY; + + List _feeds = [ + // Major Business News + Feed( + title: "Business Insider", + url: "https://feeds.businessinsider.com/custom/all" + ), + Feed( + title: "BBC Business", + url: "https://feeds.bbci.co.uk/news/business/rss.xml" + ), + Feed( + title: "Reuters Business", + url: "https://www.reutersagency.com/feed/?taxonomy=best-topics&post_type=best" + ), + Feed( + title: "Financial Times", + url: "https://www.ft.com/?format=rss" + ), + Feed( + title: "Bloomberg", + url: "https://feeds.bloomberg.com/markets/news.rss" + ), + + // Market-Specific + Feed( + title: "MarketWatch", + url: "https://feeds.marketwatch.com/marketwatch/topstories/" + ), + Feed( + title: "Seeking Alpha", + url: "https://seekingalpha.com/feed.xml" + ), + Feed( + title: "Yahoo Finance", + url: "https://finance.yahoo.com/news/rssindex" + ), + Feed( + title: "CNBC", + url: "https://www.cnbc.com/id/100003114/device/rss/rss.html" + ), + Feed( + title: "The Wall Street Journal", + url: "https://feeds.a.dj.com/rss/RSSMarketsMain.xml" + ), + + // Tech Business + Feed( + title: "TechCrunch", + url: "https://techcrunch.com/feed/" + ), + Feed( + title: "The Verge", + url: "https://www.theverge.com/rss/index.xml" + ), + Feed( + title: "Ars Technica", + url: "https://feeds.arstechnica.com/arstechnica/index" + ), + + // Company News + Feed( + title: "Fortune", + url: "https://fortune.com/feed" + ), + Feed( + title: "Forbes Business", + url: "https://www.forbes.com/business/feed/" + ), + Feed( + title: "Inc Magazine", + url: "https://www.inc.com/rss" + ), + + // Industry-Specific + Feed( + title: "Retail Dive", + url: "https://www.retaildive.com/feeds/news/" + ), + Feed( + title: "Manufacturing Dive", + url: "https://www.manufacturingdive.com/feeds/news/" + ), + Feed( + title: "Banking Dive", + url: "https://www.bankingdive.com/feeds/news/" + ), + + // Economic News + Feed( + title: "The Economist", + url: "https://www.economist.com/finance-and-economics/rss.xml" + ), + Feed( + title: "Federal Reserve News", + url: "https://www.federalreserve.gov/feeds/press_all.xml" + ), + ]; // List of rss feed URLs + List get feeds => List.unmodifiable(_feeds); + + late String _applicationStorageLocation; + String get applicationStorageLocation => _applicationStorageLocation; + static SettingsProvider of(BuildContext context) { - return context + return Provider.of(context, listen: false); } -}ç \ No newline at end of file + Future load() async { + // get documents directory dynamicly + final Directory appDocDir = await getApplicationDocumentsDirectory(); + _applicationStorageLocation = appDocDir.path; + + SharedPreferences prefs = await SharedPreferences.getInstance(); + + _OPENAI_API_KEY = prefs.getString('openai_api_key') ?? _OPENAI_API_KEY; + + List? feedStrings = prefs.getStringList('feeds'); + if (feedStrings != null) { + _feeds = feedStrings.map((fStr) { + try { + return Feed.fromJson(jsonDecode(fStr)); + } catch (e) { + // old format, parse manually + Map json = {}; + fStr.substring(1, fStr.length - 1).split(', ').forEach((pair) { + List keyValue = pair.split(': '); + String key = keyValue[0]; + String value = keyValue[1]; + + // convert bool strings to actual bools + if (value == 'true') { + json[key] = true; + } else if (value == 'false') { + json[key] = false; + } else { + json[key] = value; + } + }); + return Feed.fromJson(json); + } + }).toList(); + } + + _applicationStorageLocation = prefs.getString('application_storage_location') ?? applicationStorageLocation; + + notifyListeners(); + } + + Future save() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + + await prefs.setString('openai_api_key', _OPENAI_API_KEY); + await prefs.setStringList('feeds', _feeds.map((f) => jsonEncode(f.toJson())).toList()); + await prefs.setString('application_storage_location', _applicationStorageLocation); + } + + void setOpenAIApiKey(String key) { + _OPENAI_API_KEY = key; + save(); + notifyListeners(); + } + + void addFeed(Feed feed) { + _feeds.add(feed); + save(); + notifyListeners(); + } + + Feed updateFeed(int id, {String? title, String? url, bool? enabled}) { + for (int i = 0; i < _feeds.length; i++) { + if (_feeds[i].id == id) { + Feed updatedFeed = Feed( + url: url ?? _feeds[i].url, + title: title ?? _feeds[i].title, + enabled: enabled ?? _feeds[i].enabled, + ); + _feeds[i] = updatedFeed; + save(); + notifyListeners(); + return updatedFeed; + } + } + throw Exception("Feed with id $id not found"); + } + + void removeFeed(Feed feed) { + _feeds.removeWhere((f) => f.url == feed.url); + save(); + notifyListeners(); + } + + void setApplicationStorageLocation(String path) { + _applicationStorageLocation = path; + save(); + notifyListeners(); + } + +} + +class Feed { + + final String url; + final String title; + final bool enabled; + + Feed({required this.url, required this.title, this.enabled = true}); + + Feed.fromJson(Map json) + : url = json['url'], + title = json['title'], + enabled = json['enabled'] ?? true; + + Map toJson() => { + 'url': url, + 'title': title, + 'enabled': enabled, + }; + + int get id { + return url.hashCode ^ title.hashCode; + } +} \ No newline at end of file diff --git a/lib/utils/agrigator.dart b/lib/utils/agrigator.dart new file mode 100644 index 0000000..4d6e280 --- /dev/null +++ b/lib/utils/agrigator.dart @@ -0,0 +1,268 @@ +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:xml/xml.dart'; +import 'openai.dart'; + +const String KEYWORDS = "Business news corporate earnings revenue profit stock market trading equity shares NYSE NASDAQ stock prices quarterly results annual reports CEO announcements executive leadership management changes board directors company strategy mergers acquisitions takeovers buyouts partnerships joint ventures business deals IPO initial public offerings venture capital funding investment rounds valuation startup unicorn enterprise technology product launches innovation R&D research development market expansion international business global markets trade agreements tariffs import export supply chain logistics manufacturing production operations facilities factories plants workforce hiring layoffs restructuring downsizing labor unions strikes employee relations workplace compensation benefits corporate governance shareholder activism proxy fights dividends stock buybacks analyst ratings price targets market capitalization revenue growth profit margins EBITDA cash flow debt financing credit ratings bonds corporate strategy competitive advantage market share industry trends sector analysis retail consumer goods e-commerce technology software hardware semiconductors pharmaceuticals biotech healthcare energy oil gas renewables automotive electric vehicles aerospace defense banking financial services insurance real estate construction infrastructure telecommunications media entertainment streaming gaming hospitality travel transportation logistics shipping airlines regulatory compliance antitrust competition policy lawsuits litigation settlements data breaches cybersecurity intellectual property patents trademarks brand value customer acquisition market positioning business models revenue streams profitability sustainability ESG environmental social governance"; +List? KEYWORD_EMBEDDINGS; + +class FeedItem { + final String title; + final String description; + final String link; + List? embedding; + + FeedItem({ + required this.title, + required this.description, + required this.link, + this.embedding, + }); + + @override + String toString() { + return "FeedItem(title: $title, link: $link)"; + } + + FeedItem.fromJson(Map json) + : title = json["title"], + description = json["description"], + link = json["link"], + embedding = json["embedding"] != null + ? (json["embedding"] as List).map((e) => (e as num).toDouble()).toList() + : null; + + Map toJson() { + return { + "title": title, + "description": description, + "link": link, + if (embedding != null) "embedding": embedding, + }; + } + +} + +List parseRssFeed(String rssXml) { + final document = XmlDocument.parse(rssXml); + + // find items in the RSS structre + final items = document.findAllElements("item"); + + return items.map((item) { + final title = item.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled"; + final link = item.findElements("link").firstOrNull?.innerText ?? ""; + final description = item.findElements("description").firstOrNull?.innerText.trim() ?? ""; + + + return FeedItem( + title: title, + link: link, + description: description, + ); + }).toList(); +} + +List parseAtomFeed(String atomXml) { + final document = XmlDocument.parse(atomXml); + + // find entrys in atom feed + final entries = document.findAllElements("entry"); + + return entries.map((entry) { + final title = entry.findElements("title").firstOrNull?.innerText.trim() ?? "Untitled"; + final linkElement = entry.findElements("link").firstOrNull; + final link = linkElement?.getAttribute("href") ?? ""; + final summary = entry.findElements("summary").firstOrNull?.innerText.trim(); + final content = entry.findElements("content").firstOrNull?.innerText.trim(); + + final description = (summary ?? content ?? "").trim(); + + return FeedItem( + title: title, + link: link, + description: description, + ); + }).toList(); +} + +List parseFeed(String feedXml) { + final document = XmlDocument.parse(feedXml); + + // Check if it's an Atom feed + if (document.findAllElements('feed').isNotEmpty) { + return parseAtomFeed(feedXml); + } + + // Check if it's an RSS feed + if (document.findAllElements('rss').isNotEmpty || + document.findAllElements('channel').isNotEmpty) { + return parseRssFeed(feedXml); + } + + // Unknown feed format + throw FormatException('Unknown feed format. Expected RSS or Atom.'); +} + +Future> fetchFeed(Uri feedUri) async { + final response = await http.get(feedUri); + + + if (response.statusCode != 200) { + throw Exception("Failed to fetch feed: ${response.statusCode}"); + } + + // parse the XML response + return parseFeed(response.body); +} + +Future> fetchFeeds(List feedUris) async { + List allItems = []; + + final results = await Future.wait( + feedUris.map((uri) => fetchFeed(uri).catchError((e) { + print("Error fetching feed $uri: $e"); + return []; + })) + ); + + for (final items in results) { + allItems.addAll(items); + } + + return allItems; +} + + +// generete embeddng for a feed item +Future generateEmbedding(FeedItem item, String apiKey) async { + final openai = OpenAI(apiKey: apiKey); + + // combine tittle and descriptin + final textToEmbed = "${item.title} ${item.description}"; + + try { + final response = await openai.embeddings.create( + model: "text-embedding-3-small", + input: textToEmbed, + ); + + if (response.data.isNotEmpty) { + item.embedding = response.data.first.embedding; + } + } catch (e) { + print("Error generatng embedding: $e"); + } finally { + openai.dispose(); + } +} + +// generate embedings for multiple feed items +Future generateEmbeddings(List items, String apiKey) async { + await Future.wait( + items.map((item) => generateEmbedding(item, apiKey)) + ); +} + +Future generateKeywordEmbeddings(String apiKey) async { + + if (KEYWORD_EMBEDDINGS != null) { + return; // already generated + } + + final openai = OpenAI(apiKey: apiKey); + + try { + final response = await openai.embeddings.create( + model: "text-embedding-3-small", + input: KEYWORDS, + ); + + if (response.data.isNotEmpty) { + KEYWORD_EMBEDDINGS = response.data.first.embedding; + } + } catch (e) { + print("Error generating keyword embeddings: $e"); + } finally { + openai.dispose(); + } +} + +bool isFeedItemRelevant(FeedItem item, [double threshold = 0.25]) { + if (item.embedding == null || KEYWORD_EMBEDDINGS == null) { + throw Exception("Embeddings not available for comparison."); + } + + double similarity = cosineSimilarity(item.embedding!, KEYWORD_EMBEDDINGS!); + + return similarity >= threshold; +} + +double cosineSimilarity(List vecA, List vecB) { + if (vecA.length != vecB.length) { + throw ArgumentError("Vectors must be of the same length"); + } + + double dotProduct = 0.0; + double magnitudeA = 0.0; + double magnitudeB = 0.0; + + for (int i = 0; i < vecA.length; i++) { + dotProduct += vecA[i] * vecB[i]; + magnitudeA += vecA[i] * vecA[i]; + magnitudeB += vecB[i] * vecB[i]; + } + + if (magnitudeA == 0 || magnitudeB == 0) { + return 0.0; + } + + return dotProduct / (sqrt(magnitudeA) * sqrt(magnitudeB)); +} + +List> groupFeedItemsByEvent(List items, [double similarityThreshold = 0.7]) { + // Track which group each item belongs to and with what similarity + Map itemGrouping = {}; + List> groupedItems = []; + + for (int i = 0; i < items.length; i++) { + // Create a new group with item i as the anchor + List currentGroup = [items[i]]; + int currentGroupIndex = groupedItems.length; + + // item i belongs to its own group with similarity 1.0 + itemGrouping[i] = (groupIndex: currentGroupIndex, similarity: 1.0); + + // Check all later items + for (int j = i + 1; j < items.length; j++) { + double similarity = cosineSimilarity( + items[i].embedding!, + items[j].embedding!, + ); + + if (similarity >= similarityThreshold) { + // Check if j should join this group + if (!itemGrouping.containsKey(j)) { + // j hasn't been grouped yet, add it + currentGroup.add(items[j]); + itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity); + } else if (similarity > itemGrouping[j]!.similarity) { + // j is in another group but this is a better match + // Remove from old group + int oldGroupIndex = itemGrouping[j]!.groupIndex; + groupedItems[oldGroupIndex].remove(items[j]); + + // Add to this group + currentGroup.add(items[j]); + itemGrouping[j] = (groupIndex: currentGroupIndex, similarity: similarity); + } + } + } + + groupedItems.add(currentGroup); + } + + // Filter out empty groups (items may have been moved out) + return groupedItems.where((group) => group.isNotEmpty).toList(); +} \ No newline at end of file diff --git a/lib/utils/openai.dart b/lib/utils/openai.dart new file mode 100644 index 0000000..b36f12c --- /dev/null +++ b/lib/utils/openai.dart @@ -0,0 +1,434 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// OpenAI API client for Dart +class OpenAI { + final String apiKey; + final String baseUrl; + final http.Client _client; + + OpenAI({ + required this.apiKey, + this.baseUrl = 'https://api.openai.com/v1', + http.Client? client, + }) : _client = client ?? http.Client(); + + /// Access to chat completions API + ChatCompletions get chat => ChatCompletions(this); + + /// Access to embeddings API + Embeddings get embeddings => Embeddings(this); + + void dispose() { + _client.close(); + } +} + +/// Chat completions API +class ChatCompletions { + final OpenAI _openai; + + ChatCompletions(this._openai); + + /// Access to completions endpoint + Completions get completions => Completions(_openai); +} + +/// Completions endpoint +class Completions { + final OpenAI _openai; + + Completions(this._openai); + + /// Create a chat completion + /// + /// If [stream] is true, returns a Stream of ChatCompletionChunk + /// If [stream] is false, returns a single ChatCompletion + Future create({ + required String model, + required List messages, + bool stream = false, + StreamOptions? streamOptions, + double? temperature, + double? topP, + int? n, + List? stop, + int? maxTokens, + double? presencePenalty, + double? frequencyPenalty, + Map? logitBias, + String? user, + }) async { + final body = { + 'model': model, + 'messages': messages, + 'stream': stream, + if (streamOptions != null) 'stream_options': streamOptions.toJson(), + if (temperature != null) 'temperature': temperature, + if (topP != null) 'top_p': topP, + if (n != null) 'n': n, + if (stop != null) 'stop': stop, + if (maxTokens != null) 'max_tokens': maxTokens, + if (presencePenalty != null) 'presence_penalty': presencePenalty, + if (frequencyPenalty != null) 'frequency_penalty': frequencyPenalty, + if (logitBias != null) 'logit_bias': logitBias, + if (user != null) 'user': user, + }; + + if (stream) { + return _streamCompletion(body); + } else { + return _createCompletion(body); + } + } + + Future _createCompletion(Map body) async { + final response = await _openai._client.post( + Uri.parse('${_openai.baseUrl}/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${_openai.apiKey}', + }, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw OpenAIException( + statusCode: response.statusCode, + message: response.body, + ); + } + + return ChatCompletion.fromJson(jsonDecode(response.body)); + } + + Stream _streamCompletion( + Map body) async* { + final request = http.Request( + 'POST', + Uri.parse('${_openai.baseUrl}/chat/completions'), + ); + + request.headers.addAll({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${_openai.apiKey}', + }); + + request.body = jsonEncode(body); + + final streamedResponse = await _openai._client.send(request); + + if (streamedResponse.statusCode != 200) { + final body = await streamedResponse.stream.bytesToString(); + throw OpenAIException( + statusCode: streamedResponse.statusCode, + message: body, + ); + } + + final stream = streamedResponse.stream + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in stream) { + if (line.isEmpty) continue; + if (line.startsWith(':')) continue; // Skip comments + if (!line.startsWith('data: ')) continue; + + final data = line.substring(6); // Remove 'data: ' prefix + + if (data == '[DONE]') { + break; + } + + try { + final json = jsonDecode(data); + yield ChatCompletionChunk.fromJson(json); + } catch (e) { + // Skip malformed chunks + continue; + } + } + } +} + +/// Chat message +class ChatMessage { + final String role; + final String content; + + ChatMessage({ + required this.role, + required this.content, + }); + + Map toJson() => { + 'role': role, + 'content': content, + }; + + factory ChatMessage.fromJson(Map json) => ChatMessage( + role: json['role'], + content: json['content'], + ); + + factory ChatMessage.system(String content) => + ChatMessage(role: 'system', content: content); + + factory ChatMessage.user(String content) => + ChatMessage(role: 'user', content: content); + + factory ChatMessage.assistant(String content) => + ChatMessage(role: 'assistant', content: content); +} + +/// Stream options +class StreamOptions { + final bool includeUsage; + + StreamOptions({this.includeUsage = false}); + + Map toJson() => { + 'include_usage': includeUsage, + }; +} + +/// Chat completion response (non-streaming) +class ChatCompletion { + final String id; + final String object; + final int created; + final String model; + final List choices; + final Usage? usage; + + ChatCompletion({ + required this.id, + required this.object, + required this.created, + required this.model, + required this.choices, + this.usage, + }); + + factory ChatCompletion.fromJson(Map json) => ChatCompletion( + id: json['id'], + object: json['object'], + created: json['created'], + model: json['model'], + choices: (json['choices'] as List) + .map((c) => Choice.fromJson(c)) + .toList(), + usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null, + ); +} + +/// Chat completion chunk (streaming) +class ChatCompletionChunk { + final String id; + final String object; + final int created; + final String model; + final List choices; + final Usage? usage; + + ChatCompletionChunk({ + required this.id, + required this.object, + required this.created, + required this.model, + required this.choices, + this.usage, + }); + + factory ChatCompletionChunk.fromJson(Map json) => + ChatCompletionChunk( + id: json['id'], + object: json['object'], + created: json['created'], + model: json['model'], + choices: (json['choices'] as List) + .map((c) => ChunkChoice.fromJson(c)) + .toList(), + usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null, + ); +} + +/// Choice in non-streaming response +class Choice { + final int index; + final ChatMessage message; + final String? finishReason; + + Choice({ + required this.index, + required this.message, + this.finishReason, + }); + + factory Choice.fromJson(Map json) => Choice( + index: json['index'], + message: ChatMessage.fromJson(json['message']), + finishReason: json['finish_reason'], + ); +} + +/// Choice in streaming response +class ChunkChoice { + final int index; + final Delta? delta; + final String? finishReason; + + ChunkChoice({ + required this.index, + this.delta, + this.finishReason, + }); + + factory ChunkChoice.fromJson(Map json) => ChunkChoice( + index: json['index'], + delta: json['delta'] != null ? Delta.fromJson(json['delta']) : null, + finishReason: json['finish_reason'], + ); +} + +/// Delta content in streaming chunks +class Delta { + final String? role; + final String? content; + + Delta({ + this.role, + this.content, + }); + + factory Delta.fromJson(Map json) => Delta( + role: json['role'], + content: json['content'], + ); +} + +/// Token usage information +class Usage { + final int? promptTokens; + final int? completionTokens; + final int? totalTokens; + + Usage({ + this.promptTokens, + this.completionTokens, + this.totalTokens, + }); + + factory Usage.fromJson(Map json) => Usage( + promptTokens: json['prompt_tokens'], + completionTokens: json['completion_tokens'], + totalTokens: json['total_tokens'], + ); + + Map toJson() => { + if (promptTokens != null) 'prompt_tokens': promptTokens, + if (completionTokens != null) 'completion_tokens': completionTokens, + if (totalTokens != null) 'total_tokens': totalTokens, + }; +} + +/// OpenAI API exception +class OpenAIException implements Exception { + final int statusCode; + final String message; + + OpenAIException({ + required this.statusCode, + required this.message, + }); + + @override + String toString() => 'OpenAIException($statusCode): $message'; +} + +/// Embeddings API +class Embeddings { + final OpenAI _openai; + + Embeddings(this._openai); + + /// Create embeddings for input text + Future create({ + required String model, + required dynamic input, // String or List + String? user, + String? encodingFormat, // 'float' or 'base64' + int? dimensions, + }) async { + final body = { + 'model': model, + 'input': input, + if (user != null) 'user': user, + if (encodingFormat != null) 'encoding_format': encodingFormat, + if (dimensions != null) 'dimensions': dimensions, + }; + + final response = await _openai._client.post( + Uri.parse('${_openai.baseUrl}/embeddings'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${_openai.apiKey}', + }, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + throw OpenAIException( + statusCode: response.statusCode, + message: response.body, + ); + } + + return EmbeddingResponse.fromJson(jsonDecode(response.body)); + } +} + +/// Embedding response +class EmbeddingResponse { + final String object; + final List data; + final String model; + final Usage? usage; + + EmbeddingResponse({ + required this.object, + required this.data, + required this.model, + this.usage, + }); + + factory EmbeddingResponse.fromJson(Map json) => + EmbeddingResponse( + object: json['object'], + data: (json['data'] as List).map((e) => Embedding.fromJson(e)).toList(), + model: json['model'], + usage: json['usage'] != null ? Usage.fromJson(json['usage']) : null, + ); +} + +/// Individual embedding +class Embedding { + final String object; + final int index; + final List embedding; + + Embedding({ + required this.object, + required this.index, + required this.embedding, + }); + + factory Embedding.fromJson(Map json) => Embedding( + object: json['object'], + index: json['index'], + embedding: (json['embedding'] as List).map((e) => (e as num).toDouble()).toList(), + ); + +} \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0facfeb..9dfcd52 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,17 @@ import FlutterMacOS import Foundation import device_info_plus +import file_picker import irondash_engine_context +import path_provider_foundation +import shared_preferences_foundation import super_native_extensions func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 48747b6..6267f97 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 93FF9985C779BC8BA2DCAFDE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21B909AE7524BBE81DBDF09D /* Pods_Runner.framework */; }; + 98FD69AA4561E2418368B2C0 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1B93A602E978951E58D988DB /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 015F96669E605FAE217DD873 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 055C56BA7C5ABE1962F90F6B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 1B93A602E978951E58D988DB /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 21B909AE7524BBE81DBDF09D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 252669D05ADB4C797335A90C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* capstone_project.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "capstone_project.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* capstone_project.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = capstone_project.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +83,11 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7735CDB69586530F7D7B35C7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7F4CC95F0095086E4A6A44FE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + D52E3412500365B68CF49998 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 98FD69AA4561E2418368B2C0 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 93FF9985C779BC8BA2DCAFDE /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + E32084DAF1D94273995D8453 /* Pods */, ); sourceTree = ""; }; @@ -175,10 +188,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 21B909AE7524BBE81DBDF09D /* Pods_Runner.framework */, + 1B93A602E978951E58D988DB /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + E32084DAF1D94273995D8453 /* Pods */ = { + isa = PBXGroup; + children = ( + 015F96669E605FAE217DD873 /* Pods-Runner.debug.xcconfig */, + 7735CDB69586530F7D7B35C7 /* Pods-Runner.release.xcconfig */, + 7F4CC95F0095086E4A6A44FE /* Pods-Runner.profile.xcconfig */, + 055C56BA7C5ABE1962F90F6B /* Pods-RunnerTests.debug.xcconfig */, + 252669D05ADB4C797335A90C /* Pods-RunnerTests.release.xcconfig */, + D52E3412500365B68CF49998 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 809566E35A7164B0BF441F0A /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 06EF513114C428D54F0E2C7A /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 67D0769C6B58A1F69B052AF5 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 06EF513114C428D54F0E2C7A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +383,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 67D0769C6B58A1F69B052AF5 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 809566E35A7164B0BF441F0A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 055C56BA7C5ABE1962F90F6B /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 252669D05ADB4C797335A90C /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D52E3412500365B68CF49998 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..1112e8e 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,13 @@ com.apple.security.network.server + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.bookmarks.app-scope + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..b45bb70 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,13 @@ com.apple.security.app-sandbox + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.bookmarks.app-scope + diff --git a/pubspec.lock b/pubspec.lock index 80e6f8b..b6eba51 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" device_info_plus: dependency: transitive description: @@ -161,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200" + url: "https://pub.dev" + source: hosted + version: "10.3.7" fixnum: dependency: transitive description: @@ -182,6 +198,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -209,7 +233,7 @@ packages: source: hosted version: "16.2.4" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 @@ -344,6 +368,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -368,6 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.5" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -416,6 +496,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.44" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" skeletonizer: dependency: transitive description: @@ -517,6 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uid: + dependency: "direct main" + description: + name: uid + sha256: "712d52a000e489c3344490abe8f3defc250c73191b652f9188c7e2e17fc96b09" + url: "https://pub.dev" + source: hosted + version: "0.1.1" uuid: dependency: transitive description: @@ -565,8 +709,16 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - xml: + xdg_directories: dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index 2ee3152..cc82b34 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,12 @@ dependencies: go_router: ^16.2.4 shadcn_flutter: ^0.0.44 provider: ^6.1.5+1 + shared_preferences: ^2.5.4 + uid: ^0.1.1 + xml: ^6.6.1 + file_picker: ^10.3.7 + path_provider: ^2.1.5 + http: ^1.2.2 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.