From 81bee6fa7979d4cca7eb91602b8f778b3fba3620 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Nov 2025 11:26:13 +0000 Subject: [PATCH] feat: Implement folder management for feeds This commit introduces folder management functionality, allowing users to organize their RSS feeds into custom folders. It includes database schema changes, repository and notifier updates, and UI enhancements for creating, renaming, deleting, and moving feeds between folders. Co-authored-by: peter.aleksander --- lib/domain/data/feed_collection.dart | 23 + lib/domain/data/feed_entity.dart | 10 +- lib/domain/data/folder_entity.dart | 41 ++ lib/domain/providers/db_provider.dart | 15 + lib/domain/providers/sqlite_db_provider.dart | 81 +++- .../repositories/default_feed_repository.dart | 45 +- lib/domain/repositories/feed_repository.dart | 8 +- lib/notifiers/feed_notifier.dart | 185 +++++++-- lib/shared/app.dart | 50 ++- lib/shared/di.dart | 114 +++-- .../bottom_sheet/add_feed_bottom_sheet.dart | 61 ++- .../manage_folders_bottom_sheet.dart | 251 +++++++++++ .../bottom_sheet/move_feed_bottom_sheet.dart | 116 ++++++ lib/ui/components/feed_card.dart | 141 +++++-- lib/ui/feed_item_screen.dart | 33 +- lib/ui/home_screen.dart | 393 +++++++++++++++--- sql/create_table.sql | 25 +- sql/migration_v1_to_v2.sql | 20 + .../default_feed_repository_test.dart | 135 +++++- test/helpers/mock_factories.dart | 22 +- test/notifiers/feed_notifier_test.dart | 100 ++++- 21 files changed, 1647 insertions(+), 222 deletions(-) create mode 100644 lib/domain/data/feed_collection.dart create mode 100644 lib/domain/data/folder_entity.dart create mode 100644 lib/ui/components/bottom_sheet/manage_folders_bottom_sheet.dart create mode 100644 lib/ui/components/bottom_sheet/move_feed_bottom_sheet.dart create mode 100644 sql/migration_v1_to_v2.sql 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();