371 lines
12 KiB
Dart
371 lines
12 KiB
Dart
import 'package:florid/l10n/app_localizations.dart';
|
|
import 'package:florid/providers/settings_provider.dart';
|
|
import 'package:florid/utils/menu_actions.dart';
|
|
import 'package:florid/utils/responsive.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../providers/app_provider.dart';
|
|
import '../providers/repositories_provider.dart';
|
|
import '../widgets/app_list_item.dart';
|
|
import 'app_details_screen.dart';
|
|
|
|
class SearchScreen extends StatefulWidget {
|
|
const SearchScreen({super.key});
|
|
|
|
@override
|
|
State<SearchScreen> createState() => _SearchScreenState();
|
|
}
|
|
|
|
class _SearchScreenState extends State<SearchScreen> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final FocusNode _searchFocus = FocusNode();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Auto-focus search field when screen opens
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_searchFocus.requestFocus();
|
|
SystemChannels.textInput.invokeMethod('TextInput.show');
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_searchFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _performSearch(String query) {
|
|
final appProvider = context.read<AppProvider>();
|
|
final repositoriesProvider = context.read<RepositoriesProvider>();
|
|
appProvider.searchApps(query, repositoriesProvider: repositoriesProvider);
|
|
}
|
|
|
|
void _clearSearch() {
|
|
_searchController.clear();
|
|
final appProvider = context.read<AppProvider>();
|
|
appProvider.clearSearch();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.translucent,
|
|
onTap: () {
|
|
final focus = FocusScope.of(context);
|
|
if (!focus.hasPrimaryFocus && focus.focusedChild != null) {
|
|
focus.unfocus();
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
scrolledUnderElevation: 0,
|
|
elevation: 0,
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
|
toolbarHeight: 80,
|
|
title: TextField(
|
|
controller: _searchController,
|
|
focusNode: _searchFocus,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search F-Droid apps...',
|
|
prefixIcon: const Icon(Symbols.search),
|
|
),
|
|
textInputAction: TextInputAction.search,
|
|
onSubmitted: _performSearch,
|
|
onChanged: (query) {
|
|
// Debounced search - search after user stops typing
|
|
if (query.trim().isNotEmpty) {
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (_searchController.text.trim() == query.trim()) {
|
|
_performSearch(query.trim());
|
|
}
|
|
});
|
|
} else {
|
|
_clearSearch();
|
|
}
|
|
},
|
|
),
|
|
actions: [
|
|
Consumer<AppProvider>(
|
|
builder: (context, appProvider, child) {
|
|
if (appProvider.searchQuery.isNotEmpty) {
|
|
return IconButton(
|
|
icon: const Icon(Symbols.close),
|
|
onPressed: _clearSearch,
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
PopupMenuButton<String>(
|
|
onSelected: (value) {
|
|
switch (value) {
|
|
case 'settings':
|
|
MenuActions.showSettings(context);
|
|
break;
|
|
case 'about':
|
|
MenuActions.showAbout(context);
|
|
break;
|
|
}
|
|
},
|
|
itemBuilder: (context) => [
|
|
if (!context.isLargeScreen)
|
|
const PopupMenuItem(
|
|
value: 'settings',
|
|
child: ListTile(
|
|
leading: Icon(Symbols.settings),
|
|
title: Text('Settings'),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
const PopupMenuItem(
|
|
value: 'about',
|
|
child: ListTile(
|
|
leading: Icon(Symbols.info),
|
|
title: Text('About'),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(width: 8),
|
|
],
|
|
),
|
|
body: Consumer<AppProvider>(
|
|
builder: (context, appProvider, child) {
|
|
final state = appProvider.searchState;
|
|
final results = appProvider.searchResults;
|
|
final error = appProvider.searchError;
|
|
final query = appProvider.searchQuery;
|
|
final settingsProvider = context.read<SettingsProvider>();
|
|
|
|
final bottomPadding =
|
|
settingsProvider.themeStyle == ThemeStyle.florid ? 96.0 : 16.0;
|
|
|
|
// Show initial state
|
|
if (query.isEmpty) {
|
|
return Center(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Symbols.search,
|
|
size: 64,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Search F-Droid Apps',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Find free and open-source Android apps',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
_SearchSuggestions(
|
|
onSuggestionTap: (suggestion) {
|
|
_searchController.text = suggestion;
|
|
_performSearch(suggestion);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Show loading
|
|
if (state == LoadingState.loading) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 16),
|
|
Text(AppLocalizations.of(context)!.searching),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Show error
|
|
if (state == LoadingState.error) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Symbols.error,
|
|
size: 64,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Search failed',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
error ?? 'Unknown error occurred',
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed: () => _performSearch(query),
|
|
icon: const Icon(Symbols.refresh),
|
|
label: Text(AppLocalizations.of(context)!.retry),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Show no results
|
|
if (results.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Symbols.search_off,
|
|
size: 64,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No apps found',
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Try different keywords or check spelling',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Show results
|
|
return Column(
|
|
children: [
|
|
// Results header
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
|
),
|
|
child: Text(
|
|
'${results.length} results for "$query"',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Results list
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.fromLTRB(8, 8, 8, bottomPadding),
|
|
itemCount: results.length,
|
|
itemBuilder: (context, index) {
|
|
final app = results[index];
|
|
return AppListItem(
|
|
app: app,
|
|
showInstallStatus: false,
|
|
onTap: () {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => AppDetailsScreen(app: app),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SearchSuggestions extends StatelessWidget {
|
|
final Function(String) onSuggestionTap;
|
|
|
|
const _SearchSuggestions({required this.onSuggestionTap});
|
|
|
|
static const List<String> _suggestions = [
|
|
'browser',
|
|
'messaging',
|
|
'camera',
|
|
'music',
|
|
'games',
|
|
'calculator',
|
|
'file manager',
|
|
'note taking',
|
|
'gallery',
|
|
'keyboard',
|
|
'launcher',
|
|
'email',
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Text(
|
|
'Popular searches:',
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
|
|
alignment: WrapAlignment.center,
|
|
children: _suggestions.map((suggestion) {
|
|
return ActionChip(
|
|
label: Text(suggestion),
|
|
onPressed: () => onSuggestionTap(suggestion),
|
|
backgroundColor: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerHighest,
|
|
side: BorderSide.none,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|