diff --git a/.gitignore b/.gitignore index 9642c494..8406c417 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b56a26e3..c6aa15c6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -135,4 +135,5 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 303789365c3a8d7bc562e5e65d7e8e15218ec5c6 + COCOAPODS: 1.15.0 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 858f2d68..c15652f9 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,7 +2,7 @@ import UIKit import Flutter import Photos -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, 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/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..ffcae7f8 --- /dev/null +++ b/lib/ui/pages/landing_page/components/search_text_field.dart @@ -0,0 +1,82 @@ +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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + 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), + Text(option.label), + ], + ), + ), + ) + .toList(), + ), + const SizedBox(width: 2), + ], + ); + } +} 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 fa481f23..6680f42b 100644 --- a/lib/ui/pages/landing_page/landing_page.dart +++ b/lib/ui/pages/landing_page/landing_page.dart @@ -19,9 +19,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'; class LandingPage extends ConsumerStatefulWidget { final String title; @@ -37,6 +40,20 @@ class _LandingPageState extends ConsumerState { late IFileService fileService; late IImageService imageService; + bool _isSearchActive = false; + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + + SortOption _currentSortOption = SortOption.dateModifiedNewest; + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + Future> _getProjects() async { return database.projectDAO.getProjects(); } @@ -80,6 +97,36 @@ class _LandingPageState extends ConsumerState { } } + List _filterProjects(List projects) { + List filteredProjects = projects; + + if (_searchQuery.isNotEmpty) { + filteredProjects = filteredProjects + .where((project) => + project.name.toLowerCase().contains(_searchQuery.toLowerCase())) + .toList(); + } + + filteredProjects.sort((a, b) { + switch (_currentSortOption) { + 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); @@ -99,34 +146,71 @@ class _LandingPageState extends ConsumerState { 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) { + setState(() { + _searchQuery = value; + }); + }, + currentSortOption: _currentSortOption, + onSortOptionSelected: (option) { + FocusScope.of(context).unfocus(); + setState(() { + _currentSortOption = option; + }); + }, + + ) + : Text(widget.title), + actions: [ + SearchToggleButton( + isSearchActive: _isSearchActive, + onSearchStart: () { + setState(() { + _isSearchActive = true; + }); + }, + onSearchEnd: () { + setState(() { + _isSearchActive = false; + _searchQuery = ''; + _searchController.clear(); + }); + }, + ), + if (!_isSearchActive) const MainOverflowMenu(), + ], ), body: FutureBuilder( future: _getProjects(), builder: (BuildContext context, AsyncSnapshot> snapshot) { 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), @@ -146,21 +230,18 @@ 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); - }, - ); - } - 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, ), ), ], @@ -174,36 +255,38 @@ class _LandingPageState extends ConsumerState { } }, ), - 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(); + }, + ), + ], + ), ); } }