diff --git a/lib/domain/data/feed_collection.dart b/lib/domain/data/feed_collection.dart new file mode 100644 index 0000000..fb14ef2 --- /dev/null +++ b/lib/domain/data/feed_collection.dart @@ -0,0 +1,23 @@ +import 'package:collection/collection.dart'; +import 'package:rss_it/domain/data/feed_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; + +final class FeedCollection { + final FolderEntity? folder; + final List feeds; + + const FeedCollection({ + required this.folder, + required this.feeds, + }); + + String get displayName => + folder?.name ?? + (feeds.isEmpty ? 'Unsorted feeds' : 'Unsorted (${feeds.length})'); + + bool get isFolder => folder != null; + + /// Returns feeds sorted alphabetically to offer predictable ordering. + List get sortedFeeds => + feeds.sorted((a, b) => a.title.compareTo(b.title)); +} diff --git a/lib/domain/data/feed_entity.dart b/lib/domain/data/feed_entity.dart index ab9614e..fe7c723 100644 --- a/lib/domain/data/feed_entity.dart +++ b/lib/domain/data/feed_entity.dart @@ -2,13 +2,17 @@ import 'package:dart_scope_functions/dart_scope_functions.dart'; import 'package:rss_it_library/protos/feed.pb.dart'; final class FeedEntity { - static FeedEntity fromRemoteFeed(Feed remoteFeed) { + static FeedEntity fromRemoteFeed( + Feed remoteFeed, { + int? folderId, + }) { return FeedEntity( url: remoteFeed.url, title: remoteFeed.title, description: remoteFeed.description, thumbnailURL: remoteFeed.image, addedAt: DateTime.now(), + folderId: folderId, ); } @@ -18,6 +22,7 @@ final class FeedEntity { final String? description; final String? thumbnailURL; final DateTime addedAt; + final int? folderId; FeedEntity({ this.id, @@ -26,6 +31,7 @@ final class FeedEntity { required this.description, required this.thumbnailURL, required this.addedAt, + this.folderId, }); factory FeedEntity.fromJson(Map json) { @@ -36,6 +42,7 @@ final class FeedEntity { description: json['description'] as String?, thumbnailURL: json['thumbnail_url'] as String?, addedAt: (json['added_at'] as String).let((it) => DateTime.parse(it)), + folderId: json['folder_id'] as int?, ); } @@ -47,6 +54,7 @@ final class FeedEntity { 'description': description, 'thumbnail_url': thumbnailURL, 'added_at': addedAt.toIso8601String(), + 'folder_id': folderId, }; } } diff --git a/lib/domain/data/folder_entity.dart b/lib/domain/data/folder_entity.dart new file mode 100644 index 0000000..931208f --- /dev/null +++ b/lib/domain/data/folder_entity.dart @@ -0,0 +1,41 @@ +import 'package:dart_scope_functions/dart_scope_functions.dart'; + +final class FolderEntity { + final int? id; + final String name; + final DateTime createdAt; + + const FolderEntity({ + this.id, + required this.name, + required this.createdAt, + }); + + factory FolderEntity.fromJson(Map json) { + return FolderEntity( + id: json['id'] as int?, + name: json['name'] as String, + createdAt: (json['created_at'] as String).let(DateTime.parse), + ); + } + + Map toJson() { + return { + if (id != null) 'id': id, + 'name': name, + 'created_at': createdAt.toIso8601String(), + }; + } + + FolderEntity copyWith({ + int? id, + String? name, + DateTime? createdAt, + }) { + return FolderEntity( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + ); + } +} diff --git a/lib/domain/providers/db_provider.dart b/lib/domain/providers/db_provider.dart index 3dd7ea3..6b80069 100644 --- a/lib/domain/providers/db_provider.dart +++ b/lib/domain/providers/db_provider.dart @@ -1,5 +1,6 @@ import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/domain/data/feed_item_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; enum GetFeedsOrderBy { title, addedAt } @@ -16,7 +17,21 @@ abstract interface class DBProvider { required Iterable incomingFeedItems, }); + Future> getFolders({ + OrderByDirection orderByDirection = OrderByDirection.ascending, + }); + + Future createFolder({required FolderEntity folder}); + Future renameFolder({ + required int folderID, + required String newName, + }); + + Future deleteFolder({required int folderID}); + Future moveFeedToFolder({required int feedID, int? folderID}); + Future> getFeeds({ + int? folderID, GetFeedsOrderBy orderBy = GetFeedsOrderBy.title, OrderByDirection orderByDirection = OrderByDirection.ascending, }); diff --git a/lib/domain/providers/sqlite_db_provider.dart b/lib/domain/providers/sqlite_db_provider.dart index 1401fe0..3e4aae7 100644 --- a/lib/domain/providers/sqlite_db_provider.dart +++ b/lib/domain/providers/sqlite_db_provider.dart @@ -1,5 +1,6 @@ import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/domain/data/feed_item_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; import 'package:rss_it/domain/providers/db_provider.dart'; import 'package:sqflite/sqflite.dart'; @@ -55,6 +56,7 @@ final class SQLiteDBProvider implements DBProvider { @override Future> getFeeds({ + int? folderID, GetFeedsOrderBy orderBy = GetFeedsOrderBy.title, OrderByDirection orderByDirection = OrderByDirection.ascending, }) async { @@ -68,9 +70,13 @@ final class SQLiteDBProvider implements DBProvider { OrderByDirection.descending => 'desc', }; + final whereClause = folderID != null ? 'where folder_id = ?' : ''; final query = - 'select * from feeds order by $orderByColumn $orderByDirectionString'; - final result = await _database.rawQuery(query); + 'select * from feeds $whereClause order by $orderByColumn $orderByDirectionString'; + final result = await _database.rawQuery( + query, + folderID != null ? [folderID] : null, + ); return result.map((item) => FeedEntity.fromJson(item)); } @@ -89,4 +95,75 @@ final class SQLiteDBProvider implements DBProvider { await txn.delete('feeds', where: 'id = $feedID'); }); } + + @override + Future> getFolders({ + OrderByDirection orderByDirection = OrderByDirection.ascending, + }) async { + final orderByDirectionString = switch (orderByDirection) { + OrderByDirection.ascending => 'asc', + OrderByDirection.descending => 'desc', + }; + + final query = 'select * from folders order by name $orderByDirectionString'; + final result = await _database.rawQuery(query); + return result.map(FolderEntity.fromJson); + } + + @override + Future createFolder({required FolderEntity folder}) async { + return _database.insert('folders', folder.toJson()); + } + + @override + Future renameFolder({ + required int folderID, + required String newName, + }) { + return _database.update( + 'folders', + {'name': newName}, + where: 'id = ?', + whereArgs: [folderID], + ); + } + + @override + Future deleteFolder({required int folderID}) async { + await _database.transaction((txn) async { + final feedRows = await txn.query( + 'feeds', + columns: ['id'], + where: 'folder_id = ?', + whereArgs: [folderID], + ); + final feedIDs = feedRows.map((row) => row['id'] as int).toList(); + + if (feedIDs.isNotEmpty) { + final placeholders = List.filled(feedIDs.length, '?').join(', '); + await txn.delete( + 'feed_items', + where: 'feed_id in ($placeholders)', + whereArgs: feedIDs, + ); + await txn.delete( + 'feeds', + where: 'id in ($placeholders)', + whereArgs: feedIDs, + ); + } + + await txn.delete('folders', where: 'id = ?', whereArgs: [folderID]); + }); + } + + @override + Future moveFeedToFolder({required int feedID, int? folderID}) async { + await _database.update( + 'feeds', + {'folder_id': folderID}, + where: 'id = ?', + whereArgs: [feedID], + ); + } } diff --git a/lib/domain/repositories/default_feed_repository.dart b/lib/domain/repositories/default_feed_repository.dart index 9f636c1..df45ce0 100644 --- a/lib/domain/repositories/default_feed_repository.dart +++ b/lib/domain/repositories/default_feed_repository.dart @@ -3,6 +3,7 @@ import 'package:dart_scope_functions/dart_scope_functions.dart'; import 'package:rss_it/domain/data/enums.dart'; import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/domain/data/feed_item_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; import 'package:rss_it/domain/providers/db_provider.dart'; import 'package:rss_it/domain/repositories/feed_repository.dart'; import 'package:rss_it_library/protos/feed.pb.dart'; @@ -83,6 +84,15 @@ final class DefaultFeedRepository return result; } + @override + Future> getFoldersFromDB() async { + logger.info('Fetching folders from database...'); + final result = await _dbProvider.getFolders(); + logger.info('...folders count: ${result.length}'); + + return result; + } + @override Future> getFeedItemsFromDB(int feedID) async { logger.info('Fetching feed items from database (feedID: $feedID)...'); @@ -125,9 +135,12 @@ final class DefaultFeedRepository } @override - Future saveFeedToDB(Feed remoteFeed) async { + Future saveFeedToDB(Feed remoteFeed, {int? folderID}) async { logger.info('Saving feed to database...'); - final feedEntity = FeedEntity.fromRemoteFeed(remoteFeed); + final feedEntity = FeedEntity.fromRemoteFeed( + remoteFeed, + folderId: folderID, + ); final feedID = await _dbProvider.createFeedAndReturnID(feed: feedEntity); logger.info('...feed ID: $feedID'); @@ -145,4 +158,32 @@ final class DefaultFeedRepository await _dbProvider.deleteFeed(feedID: feedID); logger.info('...feed deleted from database.'); } + + @override + Future createFolder(String name) async { + logger.info('Creating folder with name $name...'); + final folderID = await _dbProvider.createFolder( + folder: FolderEntity(name: name, createdAt: DateTime.now()), + ); + logger.info('...folder created with id $folderID'); + return folderID; + } + + @override + Future renameFolder(int folderID, String newName) async { + logger.info('Renaming folder $folderID to $newName'); + await _dbProvider.renameFolder(folderID: folderID, newName: newName); + } + + @override + Future deleteFolder(int folderID) async { + logger.info('Deleting folder $folderID'); + await _dbProvider.deleteFolder(folderID: folderID); + } + + @override + Future moveFeedToFolder({required int feedID, int? folderID}) async { + logger.info('Moving feed $feedID to folder $folderID'); + await _dbProvider.moveFeedToFolder(feedID: feedID, folderID: folderID); + } } diff --git a/lib/domain/repositories/feed_repository.dart b/lib/domain/repositories/feed_repository.dart index 62c2fad..eece09e 100644 --- a/lib/domain/repositories/feed_repository.dart +++ b/lib/domain/repositories/feed_repository.dart @@ -1,6 +1,7 @@ import 'package:rss_it/domain/data/enums.dart'; import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/domain/data/feed_item_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; import 'package:rss_it_library/protos/feed.pb.dart'; abstract interface class FeedRepository { @@ -9,9 +10,14 @@ abstract interface class FeedRepository { Future> getFeedsFromRemote(List urls); Future> getFeedsFromDB(); Future> getFeedItemsFromDB(int feedID); + Future> getFoldersFromDB(); Future updatedFeedItemsIfNecessary(Iterable newRemoteFeeds); - Future saveFeedToDB(Feed remoteFeed); + Future saveFeedToDB(Feed remoteFeed, {int? folderID}); + Future createFolder(String name); + Future renameFolder(int folderID, String newName); + Future deleteFolder(int folderID); + Future moveFeedToFolder({required int feedID, int? folderID}); Future deleteFeedFromDB(int feedID); } diff --git a/lib/notifiers/feed_notifier.dart b/lib/notifiers/feed_notifier.dart index 2b9fb09..85c2774 100644 --- a/lib/notifiers/feed_notifier.dart +++ b/lib/notifiers/feed_notifier.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:rss_it/domain/data/enums.dart'; +import 'package:rss_it/domain/data/feed_collection.dart'; import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/domain/data/feed_item_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; import 'package:rss_it/domain/repositories/feed_repository.dart'; import 'package:simplest_logger/simplest_logger.dart'; @@ -25,6 +27,12 @@ class FeedNotifier extends ChangeNotifier with SimplestLoggerMixin { Iterable _feedItems = []; Iterable get feedItems => _feedItems; + Iterable _folders = []; + Iterable get folders => _folders; + + List _feedCollections = []; + List get feedCollections => _feedCollections; + DateTime? _lastRefreshTime; FeedNotifier({required FeedRepository feedRepositoryInstance}) @@ -36,37 +44,38 @@ class FeedNotifier extends ChangeNotifier with SimplestLoggerMixin { _isLoading = true; notifyListeners(); - final everythingInDB = await _feedRepository.getFeedsFromDB(); - _feeds = everythingInDB; - notifyListeners(); - logger.info('...feeds from DB count: ${_feeds.length}'); - - final shouldRefresh = forceRefresh || - _lastRefreshTime == null || - (DateTime.now().difference(_lastRefreshTime!).inSeconds > - _refreshInterval.inSeconds); - if (shouldRefresh) { - logger.info('...refreshing feeds...'); - final persistedFeedURLs = _feeds.map((item) => item.url); - if (persistedFeedURLs.isNotEmpty) { - logger.info('...getting remote feeds...'); - final remoteFeeds = await _feedRepository.getFeedsFromRemote( - persistedFeedURLs.toList(), - ); - logger.info('...updating feed items if necessary...'); - await _feedRepository.updatedFeedItemsIfNecessary(remoteFeeds); - logger.info('...feed items updated if necessary.'); + try { + await _refreshLocalData(); + logger.info('...feeds from DB count: ${_feeds.length}'); + logger.info('...folders from DB count: ${_folders.length}'); + + final shouldRefresh = forceRefresh || + _lastRefreshTime == null || + (DateTime.now().difference(_lastRefreshTime!).inSeconds > + _refreshInterval.inSeconds); + if (shouldRefresh) { + logger.info('...refreshing feeds...'); + final persistedFeedURLs = _feeds.map((item) => item.url); + if (persistedFeedURLs.isNotEmpty) { + logger.info('...getting remote feeds...'); + final remoteFeeds = await _feedRepository.getFeedsFromRemote( + persistedFeedURLs.toList(), + ); + logger.info('...updating feed items if necessary...'); + await _feedRepository.updatedFeedItemsIfNecessary(remoteFeeds); + logger.info('...feed items updated if necessary.'); + } + + _lastRefreshTime = DateTime.now(); + logger.info('...last refresh time: $_lastRefreshTime'); } - _lastRefreshTime = DateTime.now(); - logger.info('...last refresh time: $_lastRefreshTime'); + await _refreshLocalData(); + logger.info('...feeds loaded.'); + } finally { + _isLoading = false; + notifyListeners(); } - - final newFeeds = await _feedRepository.getFeedsFromDB(); - _isLoading = false; - _feeds = newFeeds; - notifyListeners(); - logger.info('...feeds loaded.'); } Future loadFeedItems(int feedID) async { @@ -82,7 +91,7 @@ class FeedNotifier extends ChangeNotifier with SimplestLoggerMixin { logger.info('...feed items loaded.'); } - Future addFeed(String url) async { + Future addFeed(String url, {int? folderID}) async { logger.info('Adding feed (url: $url)...'); _isLoading = true; _feedValidationStatus = FeedValidationStatus.validationInProgress; @@ -97,9 +106,12 @@ class FeedNotifier extends ChangeNotifier with SimplestLoggerMixin { logger.info('...feed is valid, getting remote feed...'); final remoteFeed = await _feedRepository.getFeedsFromRemote([url]); final feed = remoteFeed.firstOrNull; - if (feed != null) { + if (feed != null) { logger.info('...saving feed to database...'); - await _feedRepository.saveFeedToDB(feed); + await _feedRepository.saveFeedToDB( + feed, + folderID: folderID, + ); logger.info('...feed saved to database.'); } } @@ -120,6 +132,81 @@ class FeedNotifier extends ChangeNotifier with SimplestLoggerMixin { getFeeds(); } + Future createFolder(String name) async { + final trimmedName = name.trim(); + if (trimmedName.isEmpty) { + return; + } + + logger.info('Creating folder ($trimmedName)...'); + _isLoading = true; + notifyListeners(); + + try { + await _feedRepository.createFolder(trimmedName); + await _refreshLocalData(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future renameFolder({ + required int folderID, + required String newName, + }) async { + final trimmedName = newName.trim(); + if (trimmedName.isEmpty) { + return; + } + + logger.info('Renaming folder ($folderID -> $trimmedName)...'); + _isLoading = true; + notifyListeners(); + + try { + await _feedRepository.renameFolder(folderID, trimmedName); + await _refreshLocalData(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future deleteFolder(int folderID) async { + logger.info('Deleting folder ($folderID) and contained feeds...'); + _isLoading = true; + notifyListeners(); + + try { + await _feedRepository.deleteFolder(folderID); + await _refreshLocalData(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future moveFeedToFolder({ + required int feedID, + int? folderID, + }) async { + logger.info('Moving feed ($feedID) to folder ($folderID)...'); + _isLoading = true; + notifyListeners(); + + try { + await _feedRepository.moveFeedToFolder( + feedID: feedID, + folderID: folderID, + ); + await _refreshLocalData(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + void resetFeedItems() { _isLoading = false; _isLoadingFeedItems = false; @@ -127,4 +214,40 @@ class FeedNotifier extends ChangeNotifier with SimplestLoggerMixin { _feedItems = []; notifyListeners(); } + + Future _refreshLocalData() async { + final foldersInDB = await _feedRepository.getFoldersFromDB(); + final feedsInDB = await _feedRepository.getFeedsFromDB(); + _folders = foldersInDB; + _feeds = feedsInDB; + _feedCollections = _buildCollections(); + notifyListeners(); + } + + List _buildCollections() { + final groupedFeeds = >{}; + for (final feed in _feeds) { + groupedFeeds.putIfAbsent(feed.folderId, () => []).add(feed); + } + + final sortedFolders = _folders.toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + + final collections = []; + final unsortedFeeds = groupedFeeds[null] ?? []; + if (unsortedFeeds.isNotEmpty) { + collections.add( + FeedCollection(folder: null, feeds: unsortedFeeds.toList()), + ); + } + + for (final folder in sortedFolders) { + final feedsForFolder = groupedFeeds[folder.id] ?? []; + collections.add( + FeedCollection(folder: folder, feeds: feedsForFolder.toList()), + ); + } + + return collections; + } } diff --git a/lib/shared/app.dart b/lib/shared/app.dart index 2a6e5fe..1cc79bc 100644 --- a/lib/shared/app.dart +++ b/lib/shared/app.dart @@ -1,17 +1,51 @@ import 'package:flutter/material.dart'; import 'package:rss_it/ui/home_screen.dart'; -ThemeData _applicationTheme(Brightness brightness) => ThemeData.from( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - brightness: brightness, - dynamicSchemeVariant: DynamicSchemeVariant.fidelity, +ThemeData _applicationTheme(Brightness brightness) { + final colorScheme = ColorScheme.fromSeed( seedColor: switch (brightness) { Brightness.light => const Color(0xFF007AFF), Brightness.dark => const Color(0xFF0A84FF), }, - ), -); + brightness: brightness, + ); + + final base = ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + brightness: brightness, + ); + + return base.copyWith( + appBarTheme: base.appBarTheme.copyWith( + elevation: 0, + centerTitle: false, + surfaceTintColor: Colors.transparent, + ), + cardTheme: base.cardTheme.copyWith( + elevation: 1, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + dropdownMenuTheme: base.dropdownMenuTheme.copyWith( + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + ), + ); +} final class App extends StatelessWidget { const App({super.key}); @@ -19,9 +53,11 @@ final class App extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + title: 'RSSit', debugShowCheckedModeBanner: false, theme: _applicationTheme(Brightness.light), darkTheme: _applicationTheme(Brightness.dark), + themeMode: ThemeMode.system, home: const HomeScreen(), ); } diff --git a/lib/shared/di.dart b/lib/shared/di.dart index acd7a06..2957b7a 100644 --- a/lib/shared/di.dart +++ b/lib/shared/di.dart @@ -8,33 +8,48 @@ import 'package:sqflite/sqflite.dart'; final SimplestServiceLocator locator = SimplestServiceLocator.instance(); -const Map> _createTableQueries = { - 'feeds': { - 'id': 'integer primary key autoincrement', - 'url': 'text not null', - 'title': 'text not null', - 'description': 'text', - 'thumbnail_url': 'text', - 'added_at': 'datetime not null', - }, - 'feed_items': { - 'id': 'integer primary key autoincrement', - 'feed_id': 'integer not null', - 'link': 'text not null', - 'title': 'text not null', - 'description': 'text', - 'image_url': 'text', - 'published_at': 'datetime', - 'created_at': 'datetime not null', - }, -}; +const _createTableStatements = [ + ''' + create table if not exists folders ( + id integer primary key autoincrement, + name text not null, + created_at datetime not null + ) + ''', + ''' + create table if not exists feeds ( + id integer primary key autoincrement, + folder_id integer references folders(id) on delete set null, + url text not null, + title text not null, + description text, + thumbnail_url text, + added_at datetime not null + ) + ''', + ''' + create table if not exists feed_items ( + id integer primary key autoincrement, + feed_id integer not null, + link text not null, + title text not null, + description text, + image_url text, + published_at datetime, + created_at datetime not null, + foreign key (feed_id) references feeds(id) + ) + ''', +]; -const _createIndexQueries = [ - 'create index idx_feed_items_feed_id on feed_items(feed_id);', - 'create index idx_feed_items_created_at on feed_items(created_at);', - 'create index idx_feeds_url on feeds(url);', - 'create index idx_feeds_title on feeds(title);', - 'create index idx_feeds_added_at on feeds(added_at);', +const _createIndexStatements = [ + 'create index if not exists idx_folders_name on folders(name);', + 'create index if not exists idx_feed_items_feed_id on feed_items(feed_id);', + 'create index if not exists idx_feed_items_created_at on feed_items(created_at);', + 'create index if not exists idx_feeds_url on feeds(url);', + 'create index if not exists idx_feeds_title on feeds(title);', + 'create index if not exists idx_feeds_added_at on feeds(added_at);', + 'create index if not exists idx_feeds_folder_id on feeds(folder_id);', ]; Future initializeDependencies() async { @@ -57,25 +72,44 @@ Future _initializeDatabase() async { .then((value) => join(value, 'rss_it.db')); return openDatabase( databasePath, - version: 1, - onCreate: (db, _) async { - // Enforce foreign key constraints + version: 2, + onConfigure: (db) async { await db.execute('pragma foreign_keys = on;'); - - // Create tables if they don't exist yet - for (final table in _createTableQueries.entries) { - final tableName = table.key; - final columns = table.value.entries - .map((item) => '${item.key} ${item.value}') - .join(', '); - final query = 'create table if not exists $tableName ($columns)'; - await db.execute(query); + }, + onCreate: (db, _) async { + for (final statement in _createTableStatements) { + await db.execute(statement); } - // Create indexes - for (final index in _createIndexQueries) { + for (final index in _createIndexStatements) { await db.execute(index); } }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2 && newVersion >= 2) { + await _applyV2Migration(db); + } + }, + ); +} + +Future _applyV2Migration(Database db) async { + await db.execute(_createTableStatements.first); + + final feedColumns = await db.rawQuery('pragma table_info(feeds);'); + final hasFolderColumn = feedColumns.any( + (column) => column['name'] == 'folder_id', ); + if (!hasFolderColumn) { + await db.execute( + 'alter table feeds add column folder_id integer references folders(id) on delete set null;', + ); + } + + for (final index in [ + 'create index if not exists idx_folders_name on folders(name);', + 'create index if not exists idx_feeds_folder_id on feeds(folder_id);', + ]) { + await db.execute(index); + } } diff --git a/lib/ui/components/bottom_sheet/add_feed_bottom_sheet.dart b/lib/ui/components/bottom_sheet/add_feed_bottom_sheet.dart index faf8cb2..d65e291 100644 --- a/lib/ui/components/bottom_sheet/add_feed_bottom_sheet.dart +++ b/lib/ui/components/bottom_sheet/add_feed_bottom_sheet.dart @@ -3,9 +3,12 @@ import 'package:rss_it/notifiers/feed_notifier.dart'; import 'package:rss_it/shared/di.dart'; import 'package:rss_it/shared/utilities/extensions.dart'; import 'package:rss_it/ui/components/buttons/stateful_button.dart'; +import 'package:rss_it/ui/components/bottom_sheet/manage_folders_bottom_sheet.dart'; final class AddFeedBottomSheet extends StatefulWidget { - const AddFeedBottomSheet({super.key}); + const AddFeedBottomSheet({super.key, this.initialFolderId}); + + final int? initialFolderId; @override State createState() => _AddFeedBottomSheetState(); @@ -21,6 +24,7 @@ final class _AddFeedBottomSheetState extends State bool _isSubmitting = false; bool? _isValidUrl; + int? _selectedFolderId; bool get _canSubmit => _controller.text.trim().isNotEmpty && @@ -35,6 +39,7 @@ final class _AddFeedBottomSheetState extends State _animationController = BottomSheet.createAnimationController(this); _controller = TextEditingController(); _focusNode = FocusNode(); + _selectedFolderId = widget.initialFolderId; } @override @@ -90,7 +95,43 @@ final class _AddFeedBottomSheetState extends State border: OutlineInputBorder(), ), ), - const SizedBox(height: 8.0), + const SizedBox(height: 16.0), + ListenableBuilder( + listenable: _feedNotifier, + builder: (context, _) { + final folders = _feedNotifier.folders.toList(); + final entries = [ + const DropdownMenuEntry( + value: null, + label: 'No folder', + ), + ...folders.map( + (folder) => DropdownMenuEntry( + value: folder.id, + label: folder.name, + ), + ), + ]; + return DropdownMenu( + leadingIcon: const Icon(Icons.folder_open), + label: const Text('Folder (optional)'), + textStyle: context.theme.textTheme.bodyLarge, + dropdownMenuEntries: entries, + initialSelection: _selectedFolderId, + onSelected: (value) => + setState(() => _selectedFolderId = value), + ); + }, + ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: _openFolderManager, + icon: const Icon(Icons.create_new_folder_outlined), + label: const Text('Manage folders'), + ), + ), + const SizedBox(height: 8.0), StatefulButton( state: _isSubmitting ? StatefulButtonState.loading @@ -123,7 +164,12 @@ final class _AddFeedBottomSheetState extends State } setState(() => _isSubmitting = true); - _feedNotifier.addFeed(_controller.text.trim()).then((_) { + _feedNotifier + .addFeed( + _controller.text.trim(), + folderID: _selectedFolderId, + ) + .then((_) { if (mounted) { setState(() => _isSubmitting = false); _animationController.reverse().then((_) { @@ -136,4 +182,13 @@ final class _AddFeedBottomSheetState extends State } void _resetValidation() => setState(() => _isValidUrl = null); + + void _openFolderManager() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => const ManageFoldersBottomSheet(), + ); + } } diff --git a/lib/ui/components/bottom_sheet/manage_folders_bottom_sheet.dart b/lib/ui/components/bottom_sheet/manage_folders_bottom_sheet.dart new file mode 100644 index 0000000..ff3e07a --- /dev/null +++ b/lib/ui/components/bottom_sheet/manage_folders_bottom_sheet.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; +import 'package:rss_it/notifiers/feed_notifier.dart'; +import 'package:rss_it/shared/di.dart'; +import 'package:rss_it/shared/utilities/extensions.dart'; + +final class ManageFoldersBottomSheet extends StatefulWidget { + const ManageFoldersBottomSheet({super.key}); + + @override + State createState() => + _ManageFoldersBottomSheetState(); +} + +final class _ManageFoldersBottomSheetState + extends State { + late final FeedNotifier _feedNotifier; + late final TextEditingController _controller; + + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _feedNotifier = locator.get(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 16, + bottom: context.media.viewInsets.bottom + 24, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Manage folders', + style: context.theme.textTheme.headlineSmall, + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: context.theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Removing a folder permanently deletes all feeds and ' + 'their cached items inside it.', + style: context.theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ListenableBuilder( + listenable: _feedNotifier, + builder: (context, _) { + final folders = _feedNotifier.folders.toList(); + if (folders.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + 'No folders yet. Create one to get started.', + style: context.theme.textTheme.bodyMedium, + ), + ); + } + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: context.media.size.height * 0.4, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: folders.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final folder = folders[index]; + final feedCount = _feedNotifier.feeds + .where((feed) => feed.folderId == folder.id) + .length; + return ListTile( + title: Text(folder.name), + subtitle: Text( + '$feedCount feed${feedCount == 1 ? '' : 's'}', + ), + leading: const Icon(Icons.folder), + trailing: Wrap( + spacing: 4, + children: [ + IconButton( + tooltip: 'Rename folder', + onPressed: () => _renameFolder(folder), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Delete folder', + onPressed: () => _confirmDelete(folder), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ); + }, + ), + ); + }, + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _createFolder(), + decoration: const InputDecoration( + labelText: 'Folder name', + prefixIcon: Icon(Icons.create_new_folder_outlined), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isSubmitting ? null : _createFolder, + child: _isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : const Text('Create folder'), + ), + ), + ], + ), + ), + ); + } + + Future _createFolder() async { + final name = _controller.text.trim(); + if (name.isEmpty) { + return; + } + setState(() => _isSubmitting = true); + await _feedNotifier.createFolder(name); + if (!mounted) { + return; + } + setState(() { + _isSubmitting = false; + _controller.clear(); + }); + } + + Future _renameFolder(FolderEntity folder) async { + final renameController = TextEditingController(text: folder.name); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rename folder'), + content: TextField( + controller: renameController, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Folder name', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(renameController.text), + child: const Text('Rename'), + ), + ], + ), + ); + renameController.dispose(); + if (result == null || result.trim().isEmpty) { + return; + } + await _feedNotifier.renameFolder(folderID: folder.id!, newName: result); + } + + Future _confirmDelete(FolderEntity folder) async { + final shouldDelete = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete folder'), + content: Text( + 'Delete "${folder.name}" and every feed inside it? ' + 'This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + + if (!shouldDelete) { + return; + } + await _feedNotifier.deleteFolder(folder.id!); + } +} diff --git a/lib/ui/components/bottom_sheet/move_feed_bottom_sheet.dart b/lib/ui/components/bottom_sheet/move_feed_bottom_sheet.dart new file mode 100644 index 0000000..9c30582 --- /dev/null +++ b/lib/ui/components/bottom_sheet/move_feed_bottom_sheet.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:rss_it/notifiers/feed_notifier.dart'; +import 'package:rss_it/shared/di.dart'; +import 'package:rss_it/shared/utilities/extensions.dart'; + +final class MoveFeedBottomSheet extends StatefulWidget { + const MoveFeedBottomSheet({ + super.key, + required this.feedId, + required this.feedTitle, + required this.currentFolderId, + }); + + final int feedId; + final String feedTitle; + final int? currentFolderId; + + @override + State createState() => _MoveFeedBottomSheetState(); +} + +final class _MoveFeedBottomSheetState extends State { + late final FeedNotifier _feedNotifier; + int? _selectedFolderId; + + @override + void initState() { + super.initState(); + _feedNotifier = locator.get(); + _selectedFolderId = widget.currentFolderId; + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 16, + bottom: context.media.viewInsets.bottom + 24, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Move feed', + style: context.theme.textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + widget.feedTitle, + style: context.theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ListenableBuilder( + listenable: _feedNotifier, + builder: (context, _) { + final folders = _feedNotifier.folders.toList(); + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: context.media.size.height * 0.4, + ), + child: ListView( + shrinkWrap: true, + children: [ + RadioListTile( + value: null, + groupValue: _selectedFolderId, + title: const Text('Unsorted'), + onChanged: (value) => + setState(() => _selectedFolderId = value), + ), + ...folders.map( + (folder) => RadioListTile( + value: folder.id, + groupValue: _selectedFolderId, + title: Text(folder.name), + onChanged: (value) => + setState(() => _selectedFolderId = value), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _applyMove, + child: const Text('Move feed'), + ), + ), + ], + ), + ), + ); + } + + Future _applyMove() async { + await _feedNotifier.moveFeedToFolder( + feedID: widget.feedId, + folderID: _selectedFolderId, + ); + if (!mounted) { + return; + } + Navigator.of(context).pop(); + } +} diff --git a/lib/ui/components/feed_card.dart b/lib/ui/components/feed_card.dart index 6790b9d..9248efb 100644 --- a/lib/ui/components/feed_card.dart +++ b/lib/ui/components/feed_card.dart @@ -1,11 +1,12 @@ -import 'package:dart_scope_functions/dart_scope_functions.dart'; import 'package:flutter/material.dart'; import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/notifiers/feed_notifier.dart'; import 'package:rss_it/shared/di.dart'; +import 'package:rss_it/shared/utilities/extensions.dart'; +import 'package:rss_it/ui/components/bottom_sheet/move_feed_bottom_sheet.dart'; import 'package:rss_it/ui/feed_screen.dart'; -enum _FeedCardMenuAction { delete, info } +enum _FeedCardMenuAction { delete, info, move } final class FeedCard extends StatelessWidget { final FeedEntity feed; @@ -14,33 +15,82 @@ final class FeedCard extends StatelessWidget { @override Widget build(BuildContext context) { + final sanitizedTitle = feed.title.trim(); + final initials = sanitizedTitle.isNotEmpty + ? sanitizedTitle.substring(0, 1).toUpperCase() + : '?'; return Card( - elevation: 0.0, - margin: const EdgeInsets.only(bottom: 8.0), - child: ListTile( - contentPadding: const EdgeInsets.only(left: 16.0), + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + child: InkWell( onTap: () => FeedScreen.navigateTo(context, feed.id ?? -1, feed.title), - title: Text(feed.title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: feed.description - ?.takeIf((it) => it.isNotEmpty) - ?.let( - (it) => Text(it, maxLines: 2, overflow: TextOverflow.ellipsis), - ), - trailing: PopupMenuButton<_FeedCardMenuAction>( - padding: EdgeInsets.zero, - menuPadding: EdgeInsets.zero, - icon: const Icon(Icons.more_vert), - onSelected: (action) => _onMenuButtonPressed(context, action), - itemBuilder: (context) => [ - const PopupMenuItem<_FeedCardMenuAction>( - value: _FeedCardMenuAction.delete, - child: Text('Delete'), - ), - const PopupMenuItem<_FeedCardMenuAction>( - value: _FeedCardMenuAction.info, - child: Text('Info'), - ), - ], + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: context.theme.colorScheme.primaryContainer, + child: Text( + initials, + style: context.theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + feed.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.theme.textTheme.titleMedium, + ), + ), + PopupMenuButton<_FeedCardMenuAction>( + tooltip: 'Feed actions', + onSelected: (action) => _onMenuButtonPressed(context, action), + itemBuilder: (context) => const [ + PopupMenuItem<_FeedCardMenuAction>( + value: _FeedCardMenuAction.move, + child: Text('Move to folder'), + ), + PopupMenuItem<_FeedCardMenuAction>( + value: _FeedCardMenuAction.info, + child: Text('Details'), + ), + PopupMenuItem<_FeedCardMenuAction>( + value: _FeedCardMenuAction.delete, + child: Text('Delete'), + ), + ], + ), + ], + ), + if (feed.description?.isNotEmpty ?? false) ...[ + const SizedBox(height: 12), + Text( + feed.description!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: context.theme.textTheme.bodyMedium?.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 12), + Text( + feed.url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.theme.textTheme.labelMedium?.copyWith( + color: context.theme.colorScheme.primary, + ), + ), + ], + ), ), ), ); @@ -48,8 +98,9 @@ final class FeedCard extends StatelessWidget { void _onMenuButtonPressed(BuildContext context, _FeedCardMenuAction action) { final actionToExecute = switch (action) { - _FeedCardMenuAction.delete => () => _deleteFeed(context, feed.id), - _FeedCardMenuAction.info => () => _showFeedInfo(context, feed.id), + _FeedCardMenuAction.delete => () => _deleteFeed(context, feed.id), + _FeedCardMenuAction.info => () => _showFeedInfo(context, feed.id), + _FeedCardMenuAction.move => () => _moveFeed(context), }; actionToExecute.call(); @@ -94,15 +145,37 @@ final class FeedCard extends StatelessWidget { content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8.0, children: [ - ?feed.description - ?.takeIf((it) => it.isNotEmpty) - ?.let((it) => Text(it)), - feed.url.let((it) => Text(it)), + if (feed.description?.isNotEmpty ?? false) ...[ + Text(feed.description!), + const SizedBox(height: 8), + ], + Text( + feed.url, + style: context.theme.textTheme.bodyMedium?.copyWith( + color: context.theme.colorScheme.primary, + ), + ), ], ), ), ); } + + void _moveFeed(BuildContext context) { + if (feed.id == null) { + return; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => MoveFeedBottomSheet( + feedId: feed.id!, + feedTitle: feed.title, + currentFolderId: feed.folderId, + ), + ); + } } diff --git a/lib/ui/feed_item_screen.dart b/lib/ui/feed_item_screen.dart index 55df8de..a0bd353 100644 --- a/lib/ui/feed_item_screen.dart +++ b/lib/ui/feed_item_screen.dart @@ -24,13 +24,30 @@ final class FeedItemScreen extends StatefulWidget { final class _FeedItemScreenState extends State { late final WebViewController _controller; + late final Uri _initialUri; @override void initState() { super.initState(); + _initialUri = Uri.parse(widget.feedItem.link); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..loadRequest(Uri.parse(widget.feedItem.link)); + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) { + final requestedUri = Uri.tryParse(request.url); + if (requestedUri == null) { + return NavigationDecision.prevent; + } + if (!_isAllowedUri(requestedUri)) { + _showBlockedNavigationSnackBar(); + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + ), + ) + ..loadRequest(_initialUri); } @override @@ -47,6 +64,7 @@ final class _FeedItemScreenState extends State { IconButton( onPressed: () => launchUrl(Uri.parse(widget.feedItem.link)), icon: const Icon(Icons.open_in_browser), + tooltip: 'Open in browser', ), ], ), @@ -62,4 +80,17 @@ final class _FeedItemScreenState extends State { ), ); } + + bool _isAllowedUri(Uri incoming) => + incoming.host == _initialUri.host && incoming.scheme == _initialUri.scheme; + + void _showBlockedNavigationSnackBar() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Navigation blocked. Use the browser button to open external links.', + ), + ), + ); + } } diff --git a/lib/ui/home_screen.dart b/lib/ui/home_screen.dart index fa40255..9f62afd 100644 --- a/lib/ui/home_screen.dart +++ b/lib/ui/home_screen.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:rss_it/domain/data/feed_collection.dart'; +import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/notifiers/feed_notifier.dart'; import 'package:rss_it/shared/di.dart'; import 'package:rss_it/shared/utilities/extensions.dart'; import 'package:rss_it/ui/components/bottom_sheet/add_feed_bottom_sheet.dart'; +import 'package:rss_it/ui/components/bottom_sheet/manage_folders_bottom_sheet.dart'; import 'package:rss_it/ui/components/feed_card.dart'; final class HomeScreen extends StatefulWidget { @@ -22,98 +25,382 @@ final class _HomeScreenState extends State { _feedNotifier.getFeeds(forceRefresh: true); } + void _openAddFeedSheet({int? folderID}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => AddFeedBottomSheet(initialFolderId: folderID), + ); + } + + void _openFolderManager() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => const ManageFoldersBottomSheet(), + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('RSSit'), + titleSpacing: 16, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'RSSit', + style: context.theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Organize your feeds with folders', + style: context.theme.textTheme.labelLarge?.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), actions: [ IconButton( - onPressed: () => showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (context) => const AddFeedBottomSheet(), - ), - icon: const Icon(Icons.add), + tooltip: 'Refresh feeds', + onPressed: () => _feedNotifier.getFeeds(forceRefresh: true), + icon: const Icon(Icons.refresh), + ), + IconButton( + tooltip: 'Manage folders', + onPressed: _openFolderManager, + icon: const Icon(Icons.create_new_folder_outlined), ), ], ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openAddFeedSheet(), + icon: const Icon(Icons.add), + label: const Text('Add feed'), + ), body: SafeArea( - minimum: const EdgeInsets.all(16.0), - child: _FeedsList(feedNotifier: _feedNotifier), + child: _FeedDashboard( + feedNotifier: _feedNotifier, + onAddFeed: _openAddFeedSheet, + onManageFolders: _openFolderManager, + ), ), ); } } -final class _FeedsList extends StatelessWidget { +final class _FeedDashboard extends StatelessWidget { final FeedNotifier feedNotifier; + final void Function({int? folderID}) onAddFeed; + final VoidCallback onManageFolders; - const _FeedsList({required this.feedNotifier}); + const _FeedDashboard({ + required this.feedNotifier, + required this.onAddFeed, + required this.onManageFolders, + }); @override Widget build(BuildContext context) { return ListenableBuilder( listenable: feedNotifier, builder: (context, _) { - if (feedNotifier.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - final feeds = feedNotifier.feeds; - if (feeds.isEmpty) { - return const _EmptyFeedsContainer(); - } - - return RefreshIndicator( - onRefresh: () => feedNotifier.getFeeds(), - child: ListView.builder( - itemCount: feeds.length, - itemBuilder: (context, index) { - final feed = feeds.elementAt(index); - return FeedCard(feed: feed); - }, - ), + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final crossAxisCount = switch (maxWidth) { + < 600 => 1, + < 1024 => 2, + _ => 3, + }; + final horizontalPadding = switch (maxWidth) { + < 600 => 16.0, + < 1024 => 24.0, + _ => 48.0, + }; + + if (feedNotifier.isLoading && feedNotifier.feedCollections.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + final collections = feedNotifier.feedCollections; + final hasAnyFeeds = + collections.any((collection) => collection.feeds.isNotEmpty); + final hasFolders = feedNotifier.folders.isNotEmpty; + if (!hasAnyFeeds && !hasFolders) { + return _EmptyFeedsContainer(onAddFeed: () => onAddFeed()); + } + + return RefreshIndicator( + onRefresh: () => feedNotifier.getFeeds(forceRefresh: true), + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: 24, + ), + itemBuilder: (context, index) { + final collection = collections.elementAt(index); + return _FolderSection( + collection: collection, + crossAxisCount: crossAxisCount, + onAddFeed: () => onAddFeed(folderID: collection.folder?.id), + onManageFolders: onManageFolders, + ); + }, + separatorBuilder: (context, _) => const SizedBox(height: 24), + itemCount: collections.length, + ), + ); + }, ); }, ); } } -final class _EmptyFeedsContainer extends StatelessWidget { - const _EmptyFeedsContainer(); +final class _FolderSection extends StatelessWidget { + final FeedCollection collection; + final int crossAxisCount; + final VoidCallback onAddFeed; + final VoidCallback onManageFolders; + + const _FolderSection({ + required this.collection, + required this.crossAxisCount, + required this.onAddFeed, + required this.onManageFolders, + }); + + @override + Widget build(BuildContext context) { + final feeds = collection.sortedFeeds; + final folderChipColor = collection.isFolder + ? context.theme.colorScheme.secondaryContainer + : context.theme.colorScheme.primaryContainer; + + return Card( + elevation: 0, + color: context.theme.colorScheme.surfaceContainerHigh, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + collection.isFolder + ? Icons.folder_outlined + : Icons.inbox_outlined, + color: context.theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + collection.folder?.name ?? 'Unsorted feeds', + style: context.theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: folderChipColor, + borderRadius: BorderRadius.circular(99), + ), + child: Text( + '${feeds.length} feed${feeds.length == 1 ? '' : 's'}', + style: context.theme.textTheme.labelLarge, + ), + ), + ], + ), + const SizedBox(height: 16), + if (feeds.isEmpty) + _EmptyFolderState( + isFolder: collection.isFolder, + onAddFeed: onAddFeed, + onManageFolders: onManageFolders, + ) + else + _FeedGrid( + feeds: feeds, + crossAxisCount: crossAxisCount, + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: onAddFeed, + icon: const Icon(Icons.add), + label: Text( + collection.isFolder ? 'Add feed to folder' : 'Add feed', + ), + ), + if (collection.isFolder) + OutlinedButton.icon( + onPressed: onManageFolders, + icon: const Icon(Icons.folder_manage_outlined), + label: const Text('Manage folders'), + ), + ], + ), + ], + ), + ), + ); + } +} + +final class _FeedGrid extends StatelessWidget { + final List feeds; + final int crossAxisCount; + + const _FeedGrid({ + required this.feeds, + required this.crossAxisCount, + }); @override Widget build(BuildContext context) { - return SizedBox( - width: context.media.size.width, - height: context.media.size.height, + if (crossAxisCount == 1) { + return Column( + children: [ + for (final feed in feeds) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: FeedCard(feed: feed), + ), + ], + ); + } + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisExtent: 160, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: feeds.length, + itemBuilder: (context, index) { + return FeedCard(feed: feeds[index]); + }, + ); + } +} + +final class _EmptyFolderState extends StatelessWidget { + final bool isFolder; + final VoidCallback onAddFeed; + final VoidCallback onManageFolders; + + const _EmptyFolderState({ + required this.isFolder, + required this.onAddFeed, + required this.onManageFolders, + }); + + @override + Widget build(BuildContext context) { + final label = isFolder + ? 'This folder does not contain any feeds yet.' + : 'You have no uncategorized feeds.'; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: context.theme.colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: context.theme.colorScheme.outlineVariant, + ), + ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.rss_feed_outlined, - size: 56.0, - color: context.theme.colorScheme.primary, - ), Text( - 'Welcome to RSSit!', - textAlign: TextAlign.center, - style: context.theme.textTheme.headlineMedium?.copyWith( - color: context.theme.colorScheme.onSurface, - ), + label, + style: context.theme.textTheme.bodyLarge, ), - Text( - 'Add your first RSS feed to get started with personalized news and updates from your favorite websites.', - textAlign: TextAlign.center, - style: context.theme.textTheme.bodyLarge?.copyWith( - color: context.theme.colorScheme.onSurfaceVariant, - ), + const SizedBox(height: 12), + Row( + children: [ + FilledButton.tonalIcon( + onPressed: onAddFeed, + icon: const Icon(Icons.add), + label: const Text('Add feed'), + ), + const SizedBox(width: 8), + if (isFolder) + TextButton( + onPressed: onManageFolders, + child: const Text('Rename or delete folder'), + ), + ], ), ], ), ); } } + +final class _EmptyFeedsContainer extends StatelessWidget { + final VoidCallback onAddFeed; + + const _EmptyFeedsContainer({required this.onAddFeed}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.rss_feed, + size: 72, + color: context.theme.colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Welcome to RSSit!', + textAlign: TextAlign.center, + style: context.theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + 'Create folders to separate work, hobbies, or personal interests ' + 'and start subscribing to your favorite feeds.', + textAlign: TextAlign.center, + style: context.theme.textTheme.bodyLarge?.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onAddFeed, + icon: const Icon(Icons.add), + label: const Text('Add your first feed'), + ), + ], + ), + ), + ); + } +} diff --git a/sql/create_table.sql b/sql/create_table.sql index f8b9b26..8bbbad7 100644 --- a/sql/create_table.sql +++ b/sql/create_table.sql @@ -1,8 +1,15 @@ --- Enforce foreign key constraints +-- Enforce foreign key constraints on every connection pragma foreign_keys = on; -create table feeds ( +create table if not exists folders ( id integer primary key autoincrement, + name text not null, + created_at datetime not null +); + +create table if not exists feeds ( + id integer primary key autoincrement, + folder_id integer references folders(id) on delete set null, url text not null, title text not null, description text, @@ -22,11 +29,15 @@ create table feed_items ( foreign key (feed_id) references feeds(id) ); +-- Create indexes on folders table +create index if not exists idx_folders_name on folders(name); + -- Create indexes on feed_items table (feed_id, created_at) -create index idx_feed_items_feed_id on feed_items(feed_id); -create index idx_feed_items_created_at on feed_items(created_at); +create index if not exists idx_feed_items_feed_id on feed_items(feed_id); +create index if not exists idx_feed_items_created_at on feed_items(created_at); -- Create indexes on feeds table (url, title, added_at) -create index idx_feeds_url on feeds(url); -create index idx_feeds_title on feeds(title); -create index idx_feeds_added_at on feeds(added_at); \ No newline at end of file +create index if not exists idx_feeds_url on feeds(url); +create index if not exists idx_feeds_title on feeds(title); +create index if not exists idx_feeds_added_at on feeds(added_at); +create index if not exists idx_feeds_folder_id on feeds(folder_id); \ No newline at end of file diff --git a/sql/migration_v1_to_v2.sql b/sql/migration_v1_to_v2.sql new file mode 100644 index 0000000..acdedd2 --- /dev/null +++ b/sql/migration_v1_to_v2.sql @@ -0,0 +1,20 @@ +-- Schema migration from v1 -> v2 (introduces folders) +pragma foreign_keys = on; + +-- 1. Create folders table to group feeds. +create table if not exists folders ( + id integer primary key autoincrement, + name text not null, + created_at datetime not null +); + +create index if not exists idx_folders_name on folders(name); + +-- 2. Add folder reference to feeds. +alter table feeds + add column folder_id integer references folders(id) on delete set null; + +create index if not exists idx_feeds_folder_id on feeds(folder_id); + +-- Removing a folder should delete every feed and its items manually/in-app. +-- Feed deletions will continue to remove their feed_items explicitly. diff --git a/test/domain/repositories/default_feed_repository_test.dart b/test/domain/repositories/default_feed_repository_test.dart index fbdc8df..5e3653e 100644 --- a/test/domain/repositories/default_feed_repository_test.dart +++ b/test/domain/repositories/default_feed_repository_test.dart @@ -3,6 +3,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:rss_it/domain/data/enums.dart'; import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/domain/data/feed_item_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; import 'package:rss_it/domain/providers/db_provider.dart'; import 'package:rss_it/domain/repositories/default_feed_repository.dart'; import 'package:rss_it_library/protos/feed.pb.dart'; @@ -35,6 +36,13 @@ void main() { createdAt: DateTime(2024, 1, 1), ), ); + registerFallbackValue( + FolderEntity( + id: null, + name: 'Sample Folder', + createdAt: DateTime(2024, 1, 1), + ), + ); }); group('DefaultFeedRepository', () { @@ -147,28 +155,46 @@ void main() { }); }); - group('getFeedsFromDB', () { - test('returns feeds from database', () async { - final feeds = MockFactories.createFeedEntities(count: 2); + group('getFeedsFromDB', () { + test('returns feeds from database', () async { + final feeds = MockFactories.createFeedEntities(count: 2); - when(() => mockDBProvider.getFeeds()).thenAnswer((_) async => feeds); + when(() => mockDBProvider.getFeeds()).thenAnswer((_) async => feeds); - final result = await repository.getFeedsFromDB(); + final result = await repository.getFeedsFromDB(); - expect(result.length, equals(2)); - expect(result, equals(feeds)); - verify(() => mockDBProvider.getFeeds()).called(1); + expect(result.length, equals(2)); + expect(result, equals(feeds)); + verify(() => mockDBProvider.getFeeds()).called(1); + }); + + test('returns empty list when no feeds exist', () async { + when(() => mockDBProvider.getFeeds()).thenAnswer((_) async => []); + + final result = await repository.getFeedsFromDB(); + + expect(result.isEmpty, isTrue); + verify(() => mockDBProvider.getFeeds()).called(1); + }); }); - test('returns empty list when no feeds exist', () async { - when(() => mockDBProvider.getFeeds()).thenAnswer((_) async => []); + group('getFoldersFromDB', () { + test('returns folders from database', () async { + final folders = [ + MockFactories.createFolderEntity(id: 1, name: 'Work'), + MockFactories.createFolderEntity(id: 2, name: 'Personal'), + ]; - final result = await repository.getFeedsFromDB(); + when(() => mockDBProvider.getFolders()).thenAnswer( + (_) async => folders, + ); - expect(result.isEmpty, isTrue); - verify(() => mockDBProvider.getFeeds()).called(1); + final result = await repository.getFoldersFromDB(); + + expect(result, equals(folders)); + verify(() => mockDBProvider.getFolders()).called(1); + }); }); - }); group('getFeedItemsFromDB', () { test('returns feed items for specific feed', () async { @@ -416,18 +442,81 @@ void main() { }); }); - group('deleteFeedFromDB', () { - test('deletes feed from database', () async { - const feedID = 1; + group('deleteFeedFromDB', () { + test('deletes feed from database', () async { + const feedID = 1; - when( - () => mockDBProvider.deleteFeed(feedID: any(named: 'feedID')), - ).thenAnswer((_) async => {}); + when( + () => mockDBProvider.deleteFeed(feedID: any(named: 'feedID')), + ).thenAnswer((_) async => {}); - await repository.deleteFeedFromDB(feedID); + await repository.deleteFeedFromDB(feedID); - verify(() => mockDBProvider.deleteFeed(feedID: feedID)).called(1); + verify(() => mockDBProvider.deleteFeed(feedID: feedID)).called(1); + }); + }); + + group('folder operations', () { + test('createFolder delegates to DB provider', () async { + when( + () => mockDBProvider.createFolder(folder: any(named: 'folder')), + ).thenAnswer((_) async => 7); + + final result = await repository.createFolder('New Folder'); + + expect(result, equals(7)); + verify( + () => mockDBProvider.createFolder(folder: any(named: 'folder')), + ).called(1); + }); + + test('renameFolder delegates to DB provider', () async { + when( + () => mockDBProvider.renameFolder( + folderID: any(named: 'folderID'), + newName: any(named: 'newName'), + ), + ).thenAnswer((_) async => {}); + + await repository.renameFolder(1, 'Renamed'); + + verify( + () => mockDBProvider.renameFolder( + folderID: 1, + newName: 'Renamed', + ), + ).called(1); + }); + + test('deleteFolder delegates to DB provider', () async { + when( + () => mockDBProvider.deleteFolder(folderID: any(named: 'folderID')), + ).thenAnswer((_) async => {}); + + await repository.deleteFolder(5); + + verify( + () => mockDBProvider.deleteFolder(folderID: 5), + ).called(1); + }); + + test('moveFeedToFolder delegates to DB provider', () async { + when( + () => mockDBProvider.moveFeedToFolder( + feedID: any(named: 'feedID'), + folderID: any(named: 'folderID'), + ), + ).thenAnswer((_) async => {}); + + await repository.moveFeedToFolder(feedID: 10, folderID: 2); + + verify( + () => mockDBProvider.moveFeedToFolder( + feedID: 10, + folderID: 2, + ), + ).called(1); + }); }); - }); }); } diff --git a/test/helpers/mock_factories.dart b/test/helpers/mock_factories.dart index 4326b3a..c6dff27 100644 --- a/test/helpers/mock_factories.dart +++ b/test/helpers/mock_factories.dart @@ -1,9 +1,9 @@ import 'package:rss_it/domain/data/feed_entity.dart'; import 'package:rss_it/domain/data/feed_item_entity.dart'; +import 'package:rss_it/domain/data/folder_entity.dart'; import 'package:rss_it_library/protos/feed.pb.dart'; /// Factory functions for creating test data entities and protobuf messages - class MockFactories { /// Creates a test FeedEntity with optional overrides static FeedEntity createFeedEntity({ @@ -13,14 +13,16 @@ class MockFactories { String? description, String? thumbnailURL, DateTime? addedAt, + int? folderId, }) { return FeedEntity( - id: id, // Default to null to let database assign ID + id: id, url: url ?? 'https://example.com/rss.xml', title: title ?? 'Test Feed', description: description ?? 'Test Feed Description', thumbnailURL: thumbnailURL ?? 'https://example.com/thumbnail.png', addedAt: addedAt ?? DateTime(2024, 1, 1), + folderId: folderId, ); } @@ -36,7 +38,7 @@ class MockFactories { DateTime? createdAt, }) { return FeedItemEntity( - id: id, // Default to null to let database assign ID + id: id, feedID: feedID ?? 1, link: link ?? 'https://example.com/article/1', title: title ?? 'Test Article', @@ -100,6 +102,18 @@ class MockFactories { ); } + static FolderEntity createFolderEntity({ + int? id, + String? name, + DateTime? createdAt, + }) { + return FolderEntity( + id: id, + name: name ?? 'Folder ${id ?? 1}', + createdAt: createdAt ?? DateTime(2024, 1, 1), + ); + } + /// Creates a list of test FeedItemEntity objects static List createFeedItemEntities({ int feedID = 1, @@ -108,7 +122,7 @@ class MockFactories { return List.generate( count, (index) => createFeedItemEntity( - id: null, // Let database assign ID + id: null, feedID: feedID, link: 'https://example.com/article/${index + 1}', title: 'Test Article ${index + 1}', diff --git a/test/notifiers/feed_notifier_test.dart b/test/notifiers/feed_notifier_test.dart index f47ffc3..bf851bc 100644 --- a/test/notifiers/feed_notifier_test.dart +++ b/test/notifiers/feed_notifier_test.dart @@ -18,9 +18,11 @@ void main() { late MockFeedRepository mockRepository; late FeedNotifier notifier; - setUp(() { + setUp(() { mockRepository = MockFeedRepository(); notifier = FeedNotifier(feedRepositoryInstance: mockRepository); + when(() => mockRepository.getFeedsFromDB()).thenAnswer((_) async => []); + when(() => mockRepository.getFoldersFromDB()).thenAnswer((_) async => []); }); group('Initial State', () { @@ -236,9 +238,12 @@ void main() { when( () => mockRepository.getFeedsFromRemote(any()), ).thenAnswer((_) async => [MockFactories.createFeedProto()]); - when( - () => mockRepository.saveFeedToDB(any()), - ).thenAnswer((_) async => {}); + when( + () => mockRepository.saveFeedToDB( + any(), + folderID: any(named: 'folderID'), + ), + ).thenAnswer((_) async => {}); when(() => mockRepository.getFeedsFromDB()).thenAnswer((_) async => []); when( () => mockRepository.updatedFeedItemsIfNecessary(any()), @@ -263,9 +268,12 @@ void main() { when( () => mockRepository.getFeedsFromRemote(any()), ).thenAnswer((_) async => [remoteFeed]); - when( - () => mockRepository.saveFeedToDB(any()), - ).thenAnswer((_) async => {}); + when( + () => mockRepository.saveFeedToDB( + any(), + folderID: any(named: 'folderID'), + ), + ).thenAnswer((_) async => {}); when(() => mockRepository.getFeedsFromDB()).thenAnswer((_) async => []); when( () => mockRepository.updatedFeedItemsIfNecessary(any()), @@ -275,7 +283,12 @@ void main() { verify(() => mockRepository.validateFeed(url)).called(1); verify(() => mockRepository.getFeedsFromRemote(any())).called(1); - verify(() => mockRepository.saveFeedToDB(any())).called(1); + verify( + () => mockRepository.saveFeedToDB( + any(), + folderID: any(named: 'folderID'), + ), + ).called(1); }); test('does not save feed when validation fails', () async { @@ -289,7 +302,12 @@ void main() { await notifier.addFeed(url); verify(() => mockRepository.validateFeed(url)).called(1); - verifyNever(() => mockRepository.saveFeedToDB(any())); + verifyNever( + () => mockRepository.saveFeedToDB( + any(), + folderID: any(named: 'folderID'), + ), + ); }); test('does not save feed when feed already exists', () async { @@ -309,7 +327,12 @@ void main() { await notifier.addFeed(url); verify(() => mockRepository.validateFeed(url)).called(1); - verifyNever(() => mockRepository.saveFeedToDB(any())); + verifyNever( + () => mockRepository.saveFeedToDB( + any(), + folderID: any(named: 'folderID'), + ), + ); }); test('refreshes feeds after adding', () async { @@ -323,9 +346,12 @@ void main() { when( () => mockRepository.getFeedsFromRemote(any()), ).thenAnswer((_) async => [remoteFeed]); - when( - () => mockRepository.saveFeedToDB(any()), - ).thenAnswer((_) async => {}); + when( + () => mockRepository.saveFeedToDB( + any(), + folderID: any(named: 'folderID'), + ), + ).thenAnswer((_) async => {}); // getFeedsFromDB is called twice: once at start of getFeeds(), once at end when( () => mockRepository.getFeedsFromDB(), @@ -410,6 +436,54 @@ void main() { }); }); + group('folder operations', () { + test('createFolder delegates to repository', () async { + when(() => mockRepository.createFolder(any())).thenAnswer( + (_) async => 1, + ); + + await notifier.createFolder('Work'); + + verify(() => mockRepository.createFolder('Work')).called(1); + verify(() => mockRepository.getFoldersFromDB()).called(greaterThan(0)); + }); + + test('renameFolder delegates to repository', () async { + when( + () => mockRepository.renameFolder(any(), any()), + ).thenAnswer((_) async => {}); + + await notifier.renameFolder(folderID: 4, newName: 'Reading'); + + verify(() => mockRepository.renameFolder(4, 'Reading')).called(1); + }); + + test('deleteFolder delegates to repository', () async { + when( + () => mockRepository.deleteFolder(any()), + ).thenAnswer((_) async => {}); + + await notifier.deleteFolder(3); + + verify(() => mockRepository.deleteFolder(3)).called(1); + }); + + test('moveFeedToFolder delegates to repository', () async { + when( + () => mockRepository.moveFeedToFolder( + feedID: any(named: 'feedID'), + folderID: any(named: 'folderID'), + ), + ).thenAnswer((_) async => {}); + + await notifier.moveFeedToFolder(feedID: 10, folderID: 2); + + verify( + () => mockRepository.moveFeedToFolder(feedID: 10, folderID: 2), + ).called(1); + }); + }); + group('resetFeedItems', () { test('resets all feed item related state', () { notifier.resetFeedItems();