diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b22394e1..cf7a5a73 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -136,3 +136,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: aa9c826e174e713c4dad1b0d2110be4d87591fc5 COCOAPODS: 1.16.2 + diff --git a/lib/core/models/sort_option.dart b/lib/core/models/sort_option.dart new file mode 100644 index 00000000..460efdc9 --- /dev/null +++ b/lib/core/models/sort_option.dart @@ -0,0 +1,25 @@ +enum SortOption { + nameAsc, + nameDesc, + dateModifiedNewest, + dateModifiedOldest, + dateCreatedNewest, + dateCreatedOldest; + + String get label { + switch (this) { + case SortOption.nameAsc: + return 'Name (A to Z)'; + case SortOption.nameDesc: + return 'Name (Z to A)'; + case SortOption.dateModifiedNewest: + return 'Last Modified (Newest)'; + case SortOption.dateModifiedOldest: + return 'Last Modified (Oldest)'; + case SortOption.dateCreatedNewest: + return 'Date Created (Newest)'; + case SortOption.dateCreatedOldest: + return 'Date Created (Oldest)'; + } + } +} diff --git a/lib/core/providers/object/search_filter_sort_provider.dart b/lib/core/providers/object/search_filter_sort_provider.dart new file mode 100644 index 00000000..8d12e6b3 --- /dev/null +++ b/lib/core/providers/object/search_filter_sort_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:paintroid/core/models/sort_option.dart'; + +final searchActiveProvider = StateProvider((ref) => false); +final searchQueryProvider = StateProvider((ref) => ''); +final sortOptionProvider = StateProvider( + (ref) => SortOption.dateModifiedNewest, +); diff --git a/lib/ui/pages/landing_page/components/search_text_field.dart b/lib/ui/pages/landing_page/components/search_text_field.dart new file mode 100644 index 00000000..b9ec8347 --- /dev/null +++ b/lib/ui/pages/landing_page/components/search_text_field.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:paintroid/core/models/sort_option.dart'; +import 'package:paintroid/ui/theme/theme.dart'; + +class SearchTextField extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final SortOption currentSortOption; + final ValueChanged onSortOptionSelected; + + const SearchTextField({ + super.key, + required this.controller, + required this.focusNode, + required this.onChanged, + required this.currentSortOption, + required this.onSortOptionSelected, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + autofocus: true, + style: TextStyle( + color: PaintroidTheme.of(context).onSurfaceColor, + ), + decoration: InputDecoration( + hintText: 'Search projects...', + hintStyle: TextStyle( + color: + PaintroidTheme.of(context).onSurfaceColor.withOpacity(0.6), + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + onChanged: onChanged, + ), + ), + PopupMenuButton( + icon: Icon( + Icons.sort, + color: PaintroidTheme.of(context).onSurfaceColor, + ), + tooltip: 'Sort options', + onSelected: onSortOptionSelected, + itemBuilder: (context) => SortOption.values + .map( + (option) => PopupMenuItem( + value: option, + child: Row( + children: [ + Icon( + option == currentSortOption + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 18, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + option.label, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/lib/ui/pages/landing_page/components/search_toggle_button.dart b/lib/ui/pages/landing_page/components/search_toggle_button.dart new file mode 100644 index 00000000..1cd08fc1 --- /dev/null +++ b/lib/ui/pages/landing_page/components/search_toggle_button.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class SearchToggleButton extends StatelessWidget { + final bool isSearchActive; + final VoidCallback onSearchStart; + final VoidCallback onSearchEnd; + + const SearchToggleButton({ + super.key, + required this.isSearchActive, + required this.onSearchStart, + required this.onSearchEnd, + }); + + @override + Widget build(BuildContext context) { + if (isSearchActive) { + return IconButton( + icon: const Icon(Icons.close), + onPressed: onSearchEnd, + ); + } + return IconButton( + icon: const Icon(Icons.search), + onPressed: onSearchStart, + ); + } +} diff --git a/lib/ui/pages/landing_page/landing_page.dart b/lib/ui/pages/landing_page/landing_page.dart index 01c1fd8c..d1b0ba56 100644 --- a/lib/ui/pages/landing_page/landing_page.dart +++ b/lib/ui/pages/landing_page/landing_page.dart @@ -9,6 +9,7 @@ import 'package:paintroid/core/providers/object/image_service.dart'; import 'package:paintroid/core/providers/object/io_handler.dart'; import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; import 'package:paintroid/core/providers/state/workspace_state_notifier.dart'; +import 'package:paintroid/core/providers/object/search_filter_sort_provider.dart'; import 'package:paintroid/core/utils/load_image_failure.dart'; import 'package:paintroid/core/utils/widget_identifier.dart'; import 'package:paintroid/ui/pages/landing_page/components/custom_action_button.dart'; @@ -16,9 +17,12 @@ import 'package:paintroid/ui/pages/landing_page/components/image_preview.dart'; import 'package:paintroid/ui/pages/landing_page/components/main_overflow_menu.dart'; import 'package:paintroid/ui/pages/landing_page/components/project_list_tile.dart'; import 'package:paintroid/ui/pages/landing_page/components/project_overflow_menu.dart'; +import 'package:paintroid/ui/pages/landing_page/components/search_toggle_button.dart'; +import 'package:paintroid/ui/pages/landing_page/components/search_text_field.dart'; import 'package:paintroid/ui/shared/icon_svg.dart'; import 'package:paintroid/ui/theme/theme.dart'; import 'package:paintroid/ui/utils/toast_utils.dart'; +import 'package:paintroid/core/models/sort_option.dart'; import 'package:toast/toast.dart'; class LandingPage extends ConsumerStatefulWidget { @@ -35,6 +39,16 @@ class _LandingPageState extends ConsumerState { late IFileService fileService; late IImageService imageService; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + Future> _getProjects() async { return database.projectDAO.getProjects(); } @@ -78,6 +92,38 @@ class _LandingPageState extends ConsumerState { } } + List _filterProjects(List projects) { + List filteredProjects = projects; + final searchQuery = ref.watch(searchQueryProvider); + final sortOption = ref.watch(sortOptionProvider); + + if (searchQuery.isNotEmpty) { + filteredProjects = filteredProjects + .where((project) => + project.name.toLowerCase().contains(searchQuery.toLowerCase())) + .toList(); + } + + filteredProjects.sort((a, b) { + switch (sortOption) { + case SortOption.nameAsc: + return a.name.compareTo(b.name); + case SortOption.nameDesc: + return b.name.compareTo(a.name); + case SortOption.dateModifiedNewest: + return b.lastModified.compareTo(a.lastModified); + case SortOption.dateModifiedOldest: + return a.lastModified.compareTo(b.lastModified); + case SortOption.dateCreatedNewest: + return b.creationDate.compareTo(a.creationDate); + case SortOption.dateCreatedOldest: + return a.creationDate.compareTo(b.creationDate); + } + }); + + return filteredProjects; + } + @override Widget build(BuildContext context) { ToastContext().init(context); @@ -94,37 +140,76 @@ class _LandingPageState extends ConsumerState { fileService = ref.watch(IFileService.provider); imageService = ref.watch(IImageService.provider); + final isSearchActive = ref.watch(searchActiveProvider); + final currentSortOption = ref.watch(sortOptionProvider); + return Scaffold( backgroundColor: PaintroidTheme.of(context).primaryColor, appBar: AppBar( - title: Text(widget.title), - actions: const [MainOverflowMenu()], + title: isSearchActive + ? SearchTextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) { + ref.read(searchQueryProvider.notifier).state = value; + }, + currentSortOption: currentSortOption, + onSortOptionSelected: (option) { + ref.read(sortOptionProvider.notifier).state = option; + }, + ) + : Text(widget.title), + actions: [ + SearchToggleButton( + isSearchActive: isSearchActive, + onSearchStart: () { + ref.read(searchActiveProvider.notifier).state = true; + }, + onSearchEnd: () { + ref.read(searchActiveProvider.notifier).state = false; + ref.read(searchQueryProvider.notifier).state = ''; + _searchController.clear(); + }, + ), + if (!isSearchActive) const MainOverflowMenu(), + ], ), body: FutureBuilder( future: _getProjects(), builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.waiting && + !isSearchActive) { + return Center( + child: CircularProgressIndicator( + backgroundColor: PaintroidTheme.of(context).fabBackgroundColor, + ), + ); + } + if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { - if (snapshot.data!.isNotEmpty) { - latestModifiedProject = snapshot.data![0]; + final filteredProjects = _filterProjects(snapshot.data!); + if (filteredProjects.isNotEmpty) { + latestModifiedProject = filteredProjects[0]; } return Column( children: [ - Flexible( - flex: 2, - child: _ProjectPreview( - ioHandler: ioHandler, - imageService: imageService, - latestModifiedProject: latestModifiedProject, - onProjectPreviewTap: () { - if (latestModifiedProject != null) { - _openProject(latestModifiedProject, ioHandler, ref); - } else { - _clearCanvas(); - _navigateToPocketPaint(); - } - }), - ), + if (!isSearchActive) + Flexible( + flex: 2, + child: _ProjectPreview( + ioHandler: ioHandler, + imageService: imageService, + latestModifiedProject: latestModifiedProject, + onProjectPreviewTap: () { + if (latestModifiedProject != null) { + _openProject(latestModifiedProject, ioHandler, ref); + } else { + _clearCanvas(); + _navigateToPocketPaint(); + } + }), + ), Container( color: PaintroidTheme.of(context).primaryContainerColor, padding: const EdgeInsets.all(20), @@ -144,64 +229,62 @@ class _LandingPageState extends ConsumerState { flex: 3, child: ListView.builder( itemBuilder: (context, index) { - if (index != 0) { - Project project = snapshot.data![index]; - return ProjectListTile( - project: project, - imageService: imageService, - index: index, - onTap: () async { - _clearCanvas(); - _openProject(project, ioHandler, ref); - }, - ); + if (index == 0) { + return Container(); } - return Container(); + Project project = filteredProjects[index]; + return ProjectListTile( + project: project, + imageService: imageService, + index: index, + onTap: () async { + _clearCanvas(); + _openProject(project, ioHandler, ref); + }, + ); }, - itemCount: snapshot.data?.length, + itemCount: filteredProjects.length, ), ), ], ); } else { - return Center( - child: CircularProgressIndicator( - backgroundColor: PaintroidTheme.of(context).fabBackgroundColor, - ), - ); + return const SizedBox.shrink(); } }, ), - floatingActionButton: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - CustomActionButton( - heroTag: 'import_image', - icon: Icons.file_download, - hint: 'Load image', - onPressed: () async { - final bool imageLoaded = - await ioHandler.loadImage(context, this, false); - if (imageLoaded && mounted) { - _navigateToPocketPaint(); - } - }, - ), - const SizedBox( - height: 10, - ), - CustomActionButton( - key: const ValueKey(WidgetIdentifier.newImageActionButton), - heroTag: 'new_image', - icon: Icons.add, - hint: 'New image', - onPressed: () async { - _clearCanvas(); - _navigateToPocketPaint(); - }, - ), - ], - ), + floatingActionButton: isSearchActive + ? null + : Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + CustomActionButton( + heroTag: 'import_image', + icon: Icons.file_download, + hint: 'Load image', + onPressed: () async { + final bool imageLoaded = + await ioHandler.loadImage(context, this, false); + if (imageLoaded && mounted) { + _navigateToPocketPaint(); + } + }, + ), + const SizedBox( + height: 10, + ), + CustomActionButton( + key: const ValueKey(WidgetIdentifier.newImageActionButton), + heroTag: 'new_image', + icon: Icons.add, + hint: 'New image', + onPressed: () async { + _clearCanvas(); + _navigateToPocketPaint(); + }, + ), + ], + ), ); } } diff --git a/test/widget/landing_page/landing_page_test.dart b/test/widget/landing_page/landing_page_test.dart index 91b910dd..eda68f46 100644 --- a/test/widget/landing_page/landing_page_test.dart +++ b/test/widget/landing_page/landing_page_test.dart @@ -23,6 +23,7 @@ import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; import 'package:paintroid/ui/pages/landing_page/components/main_overflow_menu.dart'; import 'package:paintroid/ui/pages/landing_page/components/project_list_tile.dart'; import 'package:paintroid/ui/pages/landing_page/components/project_overflow_menu.dart'; +import 'package:paintroid/ui/pages/landing_page/components/search_text_field.dart'; import 'package:paintroid/ui/pages/workspace_page/components/top_bar/overflow_menu.dart'; import 'package:paintroid/ui/pages/workspace_page/components/top_bar/top_app_bar.dart'; import 'package:paintroid/ui/shared/dialogs/about_dialog.dart'; @@ -670,4 +671,149 @@ void main() { expect(find.byType(CircularProgressIndicator), findsOneWidget); }, ); + + testWidgets( + 'Should show search bar when search icon is tapped', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + verify(database.projectDAO); + verify(dao.getProjects()); + + final searchIcon = find.byIcon(Icons.search); + expect(searchIcon, findsOneWidget); + + await tester.tap(searchIcon); + await tester.pumpAndSettle(); + + expect(find.byType(SearchTextField), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + expect(find.text('Search projects...'), findsOneWidget); + }, + ); + + testWidgets( + 'Should hide search bar when close icon is tapped', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + expect(find.byType(SearchTextField), findsOneWidget); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.byType(SearchTextField), findsNothing); + expect(find.text('Pocket Paint'), findsOneWidget); + }, + ); + + testWidgets( + 'Should filter projects based on search query', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value(projects)); + when(imageService.getProjectPreview(filePath)) + .thenReturn(Result.ok(testFile.readAsBytesSync())); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'project1'); + await tester.pumpAndSettle(); + + expect(find.text('project1'), findsOneWidget); + expect(find.text('project2'), findsNothing); + }, + ); + + testWidgets( + 'Should show sort options menu when sort icon is tapped', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.sort)); + await tester.pumpAndSettle(); + + expect(find.text('Name (A to Z)'), findsOneWidget); + expect(find.text('Name (Z to A)'), findsOneWidget); + expect(find.text('Last Modified (Newest)'), findsOneWidget); + expect(find.text('Last Modified (Oldest)'), findsOneWidget); + expect(find.text('Date Created (Newest)'), findsOneWidget); + expect(find.text('Date Created (Oldest)'), findsOneWidget); + }, + ); + + testWidgets( + 'Should sort projects by name when name sort option is selected', + (tester) async { + final projectsToSort = [ + createProject('Project first'), + createProject('Project second'), + createProject('Project third'), + ]; + + when(database.projectDAO).thenReturn(dao); + when(deviceService.getSizeInPixels()) + .thenAnswer((_) => Future.value(const Size(1080, 1920))); + when(dao.getProjects()).thenAnswer((_) => Future.value(projectsToSort)); + when(imageService.getProjectPreview(filePath)) + .thenReturn(Result.ok(testFile.readAsBytesSync())); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.sort)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Name (A to Z)')); + await tester.pumpAndSettle(); + + final projectNames = tester + .widgetList(find.byType(Text)) + .map((widget) => widget.data) + .where((text) => + text == 'Project first' || + text == 'Project second' || + text == 'Project third') + .toList(); + + expect(projectNames[0], equals('Project second')); + expect(projectNames[1], equals('Project third')); + }, + ); + + testWidgets( + 'Should hide floating action buttons when search is active', + (tester) async { + when(database.projectDAO).thenReturn(dao); + when(dao.getProjects()).thenAnswer((_) => Future.value([])); + await tester.pumpWidget(sut); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsNWidgets(2)); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + expect(find.byType(FloatingActionButton), findsNothing); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect(find.byType(FloatingActionButton), findsNWidgets(2)); + }, + ); }