spotube/lib/pages/search/search.dart
2024-12-21 17:02:13 +06:00

266 lines
11 KiB
Dart

import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_force_update.dart';
import 'package:spotube/pages/search/sections/albums.dart';
import 'package:spotube/pages/search/sections/artists.dart';
import 'package:spotube/pages/search/sections/playlists.dart';
import 'package:spotube/pages/search/sections/tracks.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/utils/platform.dart';
class SearchPage extends HookConsumerWidget {
static const name = "search";
const SearchPage({super.key});
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final searchTerm = ref.watch(searchTermStateProvider);
final controller = useSearchController();
final auth = ref.watch(authenticationProvider);
final mediaQuery = MediaQuery.of(context);
final searchTrack = ref.watch(searchProvider(SearchType.track));
final searchAlbum = ref.watch(searchProvider(SearchType.album));
final searchPlaylist = ref.watch(searchProvider(SearchType.playlist));
final searchArtist = ref.watch(searchProvider(SearchType.artist));
final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist];
final isFetching = queries.every((s) => s.isLoading);
useEffect(() {
controller.text = searchTerm;
return null;
}, []);
final resultWidget = HookBuilder(
builder: (context) {
final controller = useScrollController();
return InterScrollbar(
controller: controller,
child: SingleChildScrollView(
controller: controller,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SearchTracksSection(),
SearchPlaylistsSection(),
Gap(20),
SearchArtistsSection(),
Gap(20),
SearchAlbumsSection(),
],
),
),
),
),
);
},
);
return SafeArea(
bottom: false,
child: Scaffold(
appBar: kIsDesktop && !kIsMacOS
? const TitleBar(automaticallyImplyLeading: true)
: null,
body: auth.asData?.value == null
? const AnonymousFallback()
: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if ((kIsMobile || kIsMacOS) && context.canPop())
const BackButton()
else
const Gap(20),
Expanded(
child: Padding(
padding: const EdgeInsets.only(
right: 20,
top: 20,
bottom: 20,
),
child: SearchAnchor(
searchController: controller,
viewBuilder: (_) => HookBuilder(builder: (context) {
final searchController =
useListenable(controller);
final update = useForceUpdate();
final suggestions = searchController.text.isEmpty
? KVStoreService.recentSearches
: KVStoreService.recentSearches
.where(
(s) =>
weightedRatio(
s.toLowerCase(),
searchController.text
.toLowerCase(),
) >
50,
)
.toList();
return ListView.builder(
itemCount: suggestions.length,
itemBuilder: (context, index) {
final suggestion = suggestions[index];
return ListTile(
leading: const Icon(SpotubeIcons.history),
title: Text(suggestion),
trailing: IconButton(
icon: const Icon(SpotubeIcons.trash),
onPressed: () {
KVStoreService.setRecentSearches(
KVStoreService.recentSearches
.where((s) => s != suggestion)
.toList(),
);
update();
},
),
onTap: () {
controller.closeView(suggestion);
ref
.read(
searchTermStateProvider.notifier)
.state = suggestion;
},
);
},
);
}),
suggestionsBuilder: (context, controller) {
return [];
},
viewOnSubmitted: (value) async {
controller.closeView(value);
Timer(
const Duration(milliseconds: 50),
() {
ref
.read(searchTermStateProvider.notifier)
.state = value;
if (value.trim().isEmpty) {
return;
}
KVStoreService.setRecentSearches(
{
value,
...KVStoreService.recentSearches,
}.toList(),
);
},
);
},
builder: (context, controller) {
return SearchBar(
autoFocus: queries.none((s) =>
s.asData?.value != null &&
!s.hasError) &&
!kIsMobile,
controller: controller,
leading: const Icon(SpotubeIcons.search),
hintText: "${context.l10n.search}...",
onTap: controller.openView,
onChanged: (_) => controller.openView(),
);
},
),
),
),
],
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: searchTerm.isEmpty
? Column(
children: [
SizedBox(
height: mediaQuery.size.height * 0.2,
),
Icon(
SpotubeIcons.web,
size: 120,
color: theme.colorScheme.onSurface
.withOpacity(0.7),
),
const SizedBox(height: 20),
Text(
context.l10n.search_to_get_results,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface
.withOpacity(0.5),
),
),
],
)
: isFetching
? Container(
constraints: BoxConstraints(
maxWidth: mediaQuery.lgAndUp
? mediaQuery.size.width * 0.5
: mediaQuery.size.width,
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Text(
context.l10n.crunching_results,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface
.withOpacity(0.7),
),
),
const SizedBox(height: 20),
const LinearProgressIndicator(),
],
),
)
: resultWidget,
),
),
],
),
),
);
}
}