parent
f698ebcfbe
commit
0caee992ec
11
README.md
11
README.md
|
|
@ -25,10 +25,11 @@ SELECT
|
||||||
?movie
|
?movie
|
||||||
(MIN(?releaseDate) as ?minReleaseDate)
|
(MIN(?releaseDate) as ?minReleaseDate)
|
||||||
WHERE {
|
WHERE {
|
||||||
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film"
|
?movie wdt:P31 wd:Q18011172;
|
||||||
wdt:P577 ?releaseDate. # P577 is the "publication date" property
|
p:P577/psv:P577 [wikibase:timePrecision ?precision];
|
||||||
?movie p:P577/psv:P577 [wikibase:timePrecision ?precision].
|
wdt:P577 ?releaseDate.
|
||||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime) && ?precision >= 10)
|
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
|
||||||
|
FILTER (?precision >= 10)
|
||||||
}
|
}
|
||||||
GROUP BY ?movie
|
GROUP BY ?movie
|
||||||
ORDER BY ?minReleaseDate
|
ORDER BY ?minReleaseDate
|
||||||
|
|
@ -38,4 +39,6 @@ Where `$limit` is the maximum number of movies that are retrieved and `$date` th
|
||||||
`$limit` is currently set to 100 and `$date` one week before the current one.
|
`$limit` is currently set to 100 and `$date` one week before the current one.
|
||||||
However, because there are multiple publication dates for most movies, the retrieved movies just need to have one publication date that is on or after `$date` for the movie to be included in the result. The `minReleaseDate` is not necessarily the release date displayed in the app, therefore some movies in the app might show up as having been released a long time ago.
|
However, because there are multiple publication dates for most movies, the retrieved movies just need to have one publication date that is on or after `$date` for the movie to be included in the result. The `minReleaseDate` is not necessarily the release date displayed in the app, therefore some movies in the app might show up as having been released a long time ago.
|
||||||
|
|
||||||
|
The wd:Q18011172 is a "film project" these are films that are unpublished uor unfinished, but films that release soon are usually finished and might already be released in some countries and might instead be wd:Q11424 "film". Therefore the query is run for each of these categories.
|
||||||
|
|
||||||
To get additional information about the movies and all release dates (in case some are before `$date` and some after) the API endpoint "https://www.wikidata.org/w/api.php?action=wbgetentities" is used.
|
To get additional information about the movies and all release dates (in case some are before `$date` and some after) the API endpoint "https://www.wikidata.org/w/api.php?action=wbgetentities" is used.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
///
|
///
|
||||||
/// Returns an [Iterable] of the selected values.
|
/// Returns an [Iterable] of the selected values.
|
||||||
///
|
///
|
||||||
/// Also see [selectInJsonWithPath] for a version that returns the path to the selected values
|
/// Also see [selectInJsonWithPath] for a version that returns the path to the selected values.
|
||||||
Iterable<T> selectInJson<T>(dynamic json, String path) {
|
Iterable<T> selectInJson<T>(dynamic json, String path) {
|
||||||
return selectInJsonWithPath<T>(json, path).map((e) => e.value);
|
return selectInJsonWithPath<T>(json, path).map((e) => e.value);
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +67,7 @@ Iterable<({T value, String path})> selectInJsonWithPath<T>(
|
||||||
} else if (json is Map) {
|
} else if (json is Map) {
|
||||||
dynamic value = json[first];
|
dynamic value = json[first];
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
yield* selectInJsonWithPath<T>(value, pathParts.join("."));
|
yield* selectInJsonWithPath<T>(value, rest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@ class WikidataProperties {
|
||||||
static const String placeOfPublication = "P291";
|
static const String placeOfPublication = "P291";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WikidataEntities {
|
||||||
|
static const String film = "Q11424";
|
||||||
|
static const String filmProject = "Q18011172";
|
||||||
|
}
|
||||||
|
|
||||||
ApiManager _wikidataApi =
|
ApiManager _wikidataApi =
|
||||||
ApiManager("https://www.wikidata.org/w/api.php?origin=*");
|
ApiManager("https://www.wikidata.org/w/api.php?origin=*");
|
||||||
|
|
||||||
|
|
@ -43,19 +48,26 @@ class WikidataMovieApi implements MovieApi {
|
||||||
@override
|
@override
|
||||||
Future<List<WikidataMovieData>> getUpcomingMovies(DateTime startDate,
|
Future<List<WikidataMovieData>> getUpcomingMovies(DateTime startDate,
|
||||||
[int count = 100]) async {
|
[int count = 100]) async {
|
||||||
Response response = await queryApi.get(
|
Response filmResponse = await queryApi.get(
|
||||||
"&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, count))}");
|
"&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, WikidataEntities.film, count))}");
|
||||||
|
Response filmProjectResponse = await queryApi.get(
|
||||||
|
"&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, WikidataEntities.filmProject, count))}");
|
||||||
|
List<Response> responses = [filmResponse, filmProjectResponse];
|
||||||
|
for (var response in responses) {
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
"The Wikidata request for upcoming movies failed with status ${response.statusCode} ${response.reasonPhrase}");
|
"The Wikidata request for upcoming movies failed with status ${response.statusCode} ${response.reasonPhrase}");
|
||||||
}
|
}
|
||||||
Map<String, dynamic> result = jsonDecode(response.body);
|
}
|
||||||
List<dynamic> entries = result["results"]["bindings"];
|
Iterable<Map<String, dynamic>> results =
|
||||||
|
responses.map((response) => jsonDecode(response.body));
|
||||||
|
Iterable<dynamic> entries =
|
||||||
|
results.expand((result) => result["results"]["bindings"]);
|
||||||
List<String> ids = entries
|
List<String> ids = entries
|
||||||
.map((entry) =>
|
.map((entry) =>
|
||||||
RegExp(r"Q\d+$").firstMatch(entry["movie"]["value"])![0]!)
|
RegExp(r"Q\d+$").firstMatch(entry["movie"]["value"])![0]!)
|
||||||
.toList();
|
.toList();
|
||||||
return _getMovieDataFromIds(ids);
|
return await _getMovieDataFromIds(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<WikidataMovieData>> _getMovieDataFromIds(
|
Future<List<WikidataMovieData>> _getMovieDataFromIds(
|
||||||
|
|
@ -92,9 +104,20 @@ class WikidataMovieApi implements MovieApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<WikidataMovieData>> searchForMovies(String searchTerm) {
|
Future<List<WikidataMovieData>> searchForMovies(String searchTerm) async {
|
||||||
// TODO: implement searchForMovies
|
String haswbstatement =
|
||||||
throw UnimplementedError();
|
"haswbstatement:${WikidataProperties.instanceOf}=${WikidataEntities.film}|${WikidataProperties.instanceOf}=${WikidataEntities.filmProject}";
|
||||||
|
String query =
|
||||||
|
"&action=query&list=search&format=json&srsearch=${Uri.encodeComponent(searchTerm)}%20$haswbstatement";
|
||||||
|
Response result = await _wikidataApi.get(query);
|
||||||
|
Map<String, dynamic> json = jsonDecode(result.body);
|
||||||
|
List<Map<String, dynamic>> searchResults =
|
||||||
|
selectInJson<Map<String, dynamic>>(json, "query.search.*").toList();
|
||||||
|
List<String> ids = searchResults
|
||||||
|
.map((result) => result["title"] as String)
|
||||||
|
.where((title) => RegExp(r"^Q\d+$").hasMatch(title))
|
||||||
|
.toList();
|
||||||
|
return await _getMovieDataFromIds(ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +156,7 @@ class WikidataMovieData extends MovieData {
|
||||||
.toList();
|
.toList();
|
||||||
List<DateWithPrecisionAndCountry> releaseDates =
|
List<DateWithPrecisionAndCountry> releaseDates =
|
||||||
selectInJson(claims, "${WikidataProperties.publicationDate}.*")
|
selectInJson(claims, "${WikidataProperties.publicationDate}.*")
|
||||||
|
.where((dateClaim) => dateClaim["rank"] != "deprecated")
|
||||||
.map<DateWithPrecisionAndCountry>((dateClaim) {
|
.map<DateWithPrecisionAndCountry>((dateClaim) {
|
||||||
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
|
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
|
||||||
String country = _getCachedLabelForEntity(selectInJson<String>(dateClaim,
|
String country = _getCachedLabelForEntity(selectInJson<String>(dateClaim,
|
||||||
|
|
@ -149,8 +173,13 @@ class WikidataMovieData extends MovieData {
|
||||||
claims, "${WikidataProperties.genre}.*.mainsnak.datavalue.value.id")
|
claims, "${WikidataProperties.genre}.*.mainsnak.datavalue.value.id")
|
||||||
.map(_getCachedLabelForEntity)
|
.map(_getCachedLabelForEntity)
|
||||||
.toList();
|
.toList();
|
||||||
WikidataMovieData movie =
|
WikidataMovieData movie = WikidataMovieData(
|
||||||
WikidataMovieData(title, releaseDates[0], entityId);
|
title,
|
||||||
|
releaseDates.isNotEmpty
|
||||||
|
? releaseDates[0]
|
||||||
|
: DateWithPrecisionAndCountry(
|
||||||
|
DateTime.now(), DatePrecision.decade, "unknown location"),
|
||||||
|
entityId);
|
||||||
movie.setDetails(
|
movie.setDetails(
|
||||||
titles: titles,
|
titles: titles,
|
||||||
releaseDates: releaseDates,
|
releaseDates: releaseDates,
|
||||||
|
|
@ -160,17 +189,19 @@ class WikidataMovieData extends MovieData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _createUpcomingMovieQuery(DateTime startDate, int limit) {
|
String _createUpcomingMovieQuery(
|
||||||
|
DateTime startDate, String instanceOf, int limit) {
|
||||||
String date = DateFormat("yyyy-MM-dd").format(startDate);
|
String date = DateFormat("yyyy-MM-dd").format(startDate);
|
||||||
return """
|
return """
|
||||||
SELECT
|
SELECT
|
||||||
?movie
|
?movie
|
||||||
(MIN(?releaseDate) as ?minReleaseDate)
|
(MIN(?releaseDate) as ?minReleaseDate)
|
||||||
WHERE {
|
WHERE {
|
||||||
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film"
|
?movie wdt:${WikidataProperties.instanceOf} wd:$instanceOf;
|
||||||
wdt:P577 ?releaseDate. # P577 is the "publication date" property
|
wdt:${WikidataProperties.publicationDate} ?releaseDate.
|
||||||
?movie p:P577/psv:P577 [wikibase:timePrecision ?precision].
|
?movie p:${WikidataProperties.publicationDate}/psv:${WikidataProperties.publicationDate} [wikibase:timePrecision ?precision].
|
||||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime) && ?precision >= 10)
|
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
|
||||||
|
FILTER (?precision >= 10)
|
||||||
}
|
}
|
||||||
GROUP BY ?movie
|
GROUP BY ?movie
|
||||||
ORDER BY ?minReleaseDate
|
ORDER BY ?minReleaseDate
|
||||||
|
|
|
||||||
116
lib/main.dart
116
lib/main.dart
|
|
@ -1,9 +1,10 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:release_schedule/api/movie_api.dart';
|
import 'package:release_schedule/model/live_search.dart';
|
||||||
import 'package:release_schedule/api/wikidata_movie_api.dart';
|
|
||||||
import 'package:release_schedule/model/movie.dart';
|
import 'package:release_schedule/model/movie.dart';
|
||||||
import 'package:release_schedule/model/movie_manager.dart';
|
import 'package:release_schedule/model/movie_manager.dart';
|
||||||
|
import 'package:release_schedule/view/movie_list.dart';
|
||||||
import 'package:release_schedule/view/movie_manager_list.dart';
|
import 'package:release_schedule/view/movie_manager_list.dart';
|
||||||
|
import 'package:release_schedule/view/swipe-transition.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
|
|
@ -27,20 +28,97 @@ class MyApp extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomePage extends StatelessWidget {
|
class HomePage extends StatefulWidget {
|
||||||
final MovieApi api = WikidataMovieApi();
|
|
||||||
final MovieManager manager;
|
final MovieManager manager;
|
||||||
|
|
||||||
HomePage(this.manager, {super.key});
|
HomePage(this.manager, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomePage> createState() => _HomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePageState extends State<HomePage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late LiveSearch liveSearch;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this, // the SingleTickerProviderStateMixin
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
liveSearch = LiveSearch(widget.manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Release Schedule"),
|
title: TextField(
|
||||||
actions: [HamburgerMenu(manager)],
|
decoration: const InputDecoration(
|
||||||
|
hintText: "Search",
|
||||||
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
body: DefaultTabController(
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value.isEmpty) {
|
||||||
|
_controller.reverse();
|
||||||
|
} else {
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
liveSearch.updateSearch(value);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [HamburgerMenu(widget.manager)],
|
||||||
|
),
|
||||||
|
body: SwipeTransition(
|
||||||
|
animation: _controller,
|
||||||
|
first: OverviewPage(manager: widget.manager),
|
||||||
|
second: SearchResultPage(liveSearch: liveSearch),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchResultPage extends StatelessWidget {
|
||||||
|
const SearchResultPage({
|
||||||
|
super.key,
|
||||||
|
required this.liveSearch,
|
||||||
|
});
|
||||||
|
|
||||||
|
final LiveSearch liveSearch;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: liveSearch,
|
||||||
|
builder: (context, child) {
|
||||||
|
return MovieList(liveSearch.searchResults);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OverviewPage extends StatelessWidget {
|
||||||
|
const OverviewPage({
|
||||||
|
super.key,
|
||||||
|
required this.manager,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MovieManager manager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 2,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -58,7 +136,18 @@ class HomePage extends StatelessWidget {
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
child: const Icon(Icons.refresh),
|
child: const Icon(Icons.refresh),
|
||||||
onPressed: () => manager.loadUpcomingMovies(),
|
onPressed: () async {
|
||||||
|
var scaffold = ScaffoldMessenger.of(context);
|
||||||
|
try {
|
||||||
|
await manager.loadUpcomingMovies();
|
||||||
|
} catch (e) {
|
||||||
|
scaffold.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text("Failed to load upcoming movies"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MovieManagerList(
|
MovieManagerList(
|
||||||
|
|
@ -74,7 +163,6 @@ class HomePage extends StatelessWidget {
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +177,16 @@ class HamburgerMenu extends StatelessWidget {
|
||||||
icon: const Icon(Icons.menu),
|
icon: const Icon(Icons.menu),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
return [
|
return [
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Text("Remove irrelevant"),
|
||||||
|
onTap: () => manager.removeMoviesWhere((movie) =>
|
||||||
|
!movie.bookmarked &&
|
||||||
|
!(movie.releaseDates?.any((date) =>
|
||||||
|
date.precision >= DatePrecision.month &&
|
||||||
|
date.date.isAfter(DateTime.now()
|
||||||
|
.subtract(const Duration(days: 30)))) ??
|
||||||
|
false)),
|
||||||
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: const Text("Remove all not bookmarked"),
|
child: const Text("Remove all not bookmarked"),
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:release_schedule/model/delayed_function_caller.dart';
|
||||||
|
import 'package:release_schedule/model/movie.dart';
|
||||||
|
import 'package:release_schedule/model/movie_manager.dart';
|
||||||
|
|
||||||
|
class LiveSearch extends ChangeNotifier {
|
||||||
|
String searchTerm = "";
|
||||||
|
List<MovieData> searchResults = [];
|
||||||
|
Duration minTimeBetweenRequests = const Duration(milliseconds: 200);
|
||||||
|
late final DelayedFunctionCaller _searchCaller;
|
||||||
|
final MovieManager manager;
|
||||||
|
bool searchingOnline = false;
|
||||||
|
|
||||||
|
LiveSearch(this.manager) {
|
||||||
|
_searchCaller = DelayedFunctionCaller(searchOnline, minTimeBetweenRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateSearch(String search) {
|
||||||
|
searchTerm = search;
|
||||||
|
if (searchTerm.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchResults = manager.localSearch(search);
|
||||||
|
_searchCaller.call();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void searchOnline() async {
|
||||||
|
if (searchTerm.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchingOnline) {
|
||||||
|
_searchCaller.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchingOnline = true;
|
||||||
|
try {
|
||||||
|
String startedSearching = searchTerm;
|
||||||
|
List<MovieData> onlineResults =
|
||||||
|
await movieManager.onlineSearch(searchTerm);
|
||||||
|
searchingOnline = false;
|
||||||
|
// if the search term has changed since we started searching, ignore the results
|
||||||
|
if (startedSearching != searchTerm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<MovieData> localResults = manager.localSearch(searchTerm);
|
||||||
|
localResults.removeWhere((element) => onlineResults.contains(element));
|
||||||
|
searchResults = onlineResults + localResults;
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
searchingOnline = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:release_schedule/api/wikidata_movie_api.dart';
|
||||||
import 'package:release_schedule/model/delayed_function_caller.dart';
|
import 'package:release_schedule/model/delayed_function_caller.dart';
|
||||||
import 'package:release_schedule/model/local_movie_storage.dart';
|
import 'package:release_schedule/model/local_movie_storage.dart';
|
||||||
import 'package:release_schedule/model/movie.dart';
|
import 'package:release_schedule/model/movie.dart';
|
||||||
|
import 'package:release_schedule/model/search.dart';
|
||||||
|
|
||||||
final movieManager = MovieManager(WikidataMovieApi(),
|
final movieManager = MovieManager(WikidataMovieApi(),
|
||||||
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable));
|
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable));
|
||||||
|
|
@ -15,7 +16,7 @@ class MovieManager extends ChangeNotifier {
|
||||||
final LocalMovieStorage cache;
|
final LocalMovieStorage cache;
|
||||||
final MovieApi api;
|
final MovieApi api;
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
DelayedFunctionCaller? cacheUpdater;
|
late final DelayedFunctionCaller cacheUpdater;
|
||||||
bool cacheLoaded = false;
|
bool cacheLoaded = false;
|
||||||
|
|
||||||
MovieManager(this.api, this.cache) {
|
MovieManager(this.api, this.cache) {
|
||||||
|
|
@ -31,7 +32,7 @@ class MovieManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _moviesModified({bool withoutAddingOrRemoving = false}) {
|
void _moviesModified({bool withoutAddingOrRemoving = false}) {
|
||||||
cacheUpdater?.call();
|
cacheUpdater.call();
|
||||||
if (!withoutAddingOrRemoving) {
|
if (!withoutAddingOrRemoving) {
|
||||||
// only notify listeners if movies are added or removed
|
// only notify listeners if movies are added or removed
|
||||||
// if they are modified in place they will notify listeners themselves
|
// if they are modified in place they will notify listeners themselves
|
||||||
|
|
@ -108,10 +109,19 @@ class MovieManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only search locally cached movies.
|
/// Only search locally cached movies.
|
||||||
localSearch(String search) {}
|
List<MovieData> localSearch(String search) {
|
||||||
|
var results = searchList(
|
||||||
|
movies,
|
||||||
|
search,
|
||||||
|
(movie) => [
|
||||||
|
movie.title,
|
||||||
|
...(movie.titles?.map((title) => title.title) ?? []),
|
||||||
|
]);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/// Online search for movies.
|
/// Online search for movies.
|
||||||
Future<List<MovieData>> search(String search) async {
|
Future<List<MovieData>> onlineSearch(String search) async {
|
||||||
List<MovieData> movies = await api.searchForMovies(search);
|
List<MovieData> movies = await api.searchForMovies(search);
|
||||||
return addMovies(movies);
|
return addMovies(movies);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
class Scored<T> {
|
||||||
|
T data;
|
||||||
|
double score;
|
||||||
|
Scored(this.data, this.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<T> searchList<T>(
|
||||||
|
List<T> list,
|
||||||
|
String search,
|
||||||
|
List<String> Function(T item) getTexts,
|
||||||
|
) {
|
||||||
|
List<Scored<T>> scored = list.map((e) {
|
||||||
|
double score = 0;
|
||||||
|
List<String> texts = getTexts(e);
|
||||||
|
for (var text in texts) {
|
||||||
|
score += searchMatch(search, text);
|
||||||
|
}
|
||||||
|
return Scored(e, score);
|
||||||
|
}).toList();
|
||||||
|
scored = scored.where((element) => element.score > 0.7).toList();
|
||||||
|
scored.sort((a, b) => (b.score - a.score).sign.toInt());
|
||||||
|
return scored.map((e) => e.data).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
double searchMatch(String search, String text) {
|
||||||
|
double matchPoints = 0;
|
||||||
|
List<String> searchParts = [search.toLowerCase()];
|
||||||
|
List<String> textParts = [text.toLowerCase()];
|
||||||
|
|
||||||
|
while (searchParts.isNotEmpty && textParts.isNotEmpty) {
|
||||||
|
int bestSpi = 0;
|
||||||
|
int bestTpi = 0;
|
||||||
|
int bestSci = 0;
|
||||||
|
int bestTci = 0;
|
||||||
|
int bestLength = 0;
|
||||||
|
|
||||||
|
for (int spi = 0; spi < searchParts.length; spi++) {
|
||||||
|
String searchPart = searchParts[spi];
|
||||||
|
for (int tpi = 0; tpi < textParts.length; tpi++) {
|
||||||
|
String textPart = textParts[tpi];
|
||||||
|
for (int sci = 0; sci < searchPart.length; sci++) {
|
||||||
|
for (int tci = 0; tci < textPart.length; tci++) {
|
||||||
|
int length = 0;
|
||||||
|
int maxLength = min(searchPart.length - sci, textPart.length - tci);
|
||||||
|
while (length < maxLength &&
|
||||||
|
searchPart[sci + length] == textPart[tci + length]) {
|
||||||
|
length++;
|
||||||
|
}
|
||||||
|
if (length > bestLength) {
|
||||||
|
bestSpi = spi;
|
||||||
|
bestTpi = tpi;
|
||||||
|
bestSci = sci;
|
||||||
|
bestTci = tci;
|
||||||
|
bestLength = length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestLength == 0) break;
|
||||||
|
matchPoints += bestLength.toDouble() - 0.5;
|
||||||
|
String searchPart = searchParts[bestSpi];
|
||||||
|
searchParts.removeAt(bestSpi);
|
||||||
|
searchParts.addAll([
|
||||||
|
searchPart.substring(0, bestSci),
|
||||||
|
searchPart.substring(bestSci + bestLength),
|
||||||
|
]);
|
||||||
|
searchParts.removeWhere((x) => x.isEmpty);
|
||||||
|
|
||||||
|
String textPart = textParts[bestTpi];
|
||||||
|
textParts.removeAt(bestTpi);
|
||||||
|
textParts.addAll([
|
||||||
|
textPart.substring(0, bestTci),
|
||||||
|
textPart.substring(bestTci + bestLength),
|
||||||
|
]);
|
||||||
|
textParts.removeWhere((x) => x.isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize result
|
||||||
|
double matchScore = matchPoints / (search.length - 0.5);
|
||||||
|
double missCut = pow(
|
||||||
|
(textParts.fold(0, (acc, part) => acc + part.length) / text.length),
|
||||||
|
2) *
|
||||||
|
0.2 *
|
||||||
|
matchScore;
|
||||||
|
double score = matchScore - missCut;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,8 @@ class MovieItem extends StatelessWidget {
|
||||||
title: Text(movie.title),
|
title: Text(movie.title),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
"${dateRelativeToNow(movie.releaseDate.date)}, ${movie.releaseDate.toString()}, ${movie.genres?.join(", ") ?? ""}"),
|
"${dateRelativeToNow(movie.releaseDate.date)}, ${movie.releaseDate.toString()}, ${movie.genres?.join(", ") ?? ""}"),
|
||||||
trailing: TextButton(
|
trailing: IconButton(
|
||||||
child: Icon(movie.bookmarked
|
icon: Icon(movie.bookmarked
|
||||||
? Icons.bookmark_added
|
? Icons.bookmark_added
|
||||||
: Icons.bookmark_border),
|
: Icons.bookmark_border),
|
||||||
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
|
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,14 @@ class MoviePage extends StatelessWidget {
|
||||||
animation: movie,
|
animation: movie,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: Text(movie.title), actions: [
|
||||||
title: Text(movie.title),
|
IconButton(
|
||||||
|
icon: Icon(movie.bookmarked
|
||||||
|
? Icons.bookmark_added
|
||||||
|
: Icons.bookmark_outline),
|
||||||
|
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A widget that transitions between two child widget by clipping them so that
|
||||||
|
/// the first one slides out of view and the second one slides into view.
|
||||||
|
class SwipeTransition extends StatelessWidget {
|
||||||
|
final Widget first;
|
||||||
|
final Widget second;
|
||||||
|
late final CurvedAnimation firstAnimation;
|
||||||
|
late final CurvedAnimation secondAnimation;
|
||||||
|
final Animation<double> animation;
|
||||||
|
|
||||||
|
SwipeTransition({
|
||||||
|
Key? key,
|
||||||
|
required this.first,
|
||||||
|
required this.second,
|
||||||
|
required this.animation,
|
||||||
|
}) : super(key: key) {
|
||||||
|
firstAnimation = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutSine,
|
||||||
|
);
|
||||||
|
secondAnimation = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeInSine,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
ClipWithRect(
|
||||||
|
clipRect: Rect.fromLTRB(
|
||||||
|
0,
|
||||||
|
firstAnimation.value,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
child: first,
|
||||||
|
),
|
||||||
|
ClipWithRect(
|
||||||
|
clipRect: Rect.fromLTRB(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
secondAnimation.value,
|
||||||
|
),
|
||||||
|
child: second,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClipWithRect extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final Rect clipRect;
|
||||||
|
|
||||||
|
const ClipWithRect({
|
||||||
|
Key? key,
|
||||||
|
required this.child,
|
||||||
|
required this.clipRect,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClipRect(
|
||||||
|
clipper: _RectClipper(clipRect),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RectClipper extends CustomClipper<Rect> {
|
||||||
|
final Rect clipRect;
|
||||||
|
|
||||||
|
_RectClipper(this.clipRect);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Rect getClip(Size size) {
|
||||||
|
return Rect.fromLTRB(
|
||||||
|
clipRect.left * size.width,
|
||||||
|
clipRect.top * size.height,
|
||||||
|
clipRect.right * size.width,
|
||||||
|
clipRect.bottom * size.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReclip(covariant CustomClipper<Rect> oldClipper) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ void main() {
|
||||||
MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage());
|
MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage());
|
||||||
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
|
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
|
||||||
|
|
||||||
expect(find.text('Release Schedule'), findsOneWidget);
|
expect(find.text('Search'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('displays list of releases', (WidgetTester tester) async {
|
testWidgets('displays list of releases', (WidgetTester tester) async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue