parent
f698ebcfbe
commit
0caee992ec
11
README.md
11
README.md
|
@ -25,10 +25,11 @@ SELECT
|
|||
?movie
|
||||
(MIN(?releaseDate) as ?minReleaseDate)
|
||||
WHERE {
|
||||
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film"
|
||||
wdt:P577 ?releaseDate. # P577 is the "publication date" property
|
||||
?movie p:P577/psv:P577 [wikibase:timePrecision ?precision].
|
||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime) && ?precision >= 10)
|
||||
?movie wdt:P31 wd:Q18011172;
|
||||
p:P577/psv:P577 [wikibase:timePrecision ?precision];
|
||||
wdt:P577 ?releaseDate.
|
||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
|
||||
FILTER (?precision >= 10)
|
||||
}
|
||||
GROUP BY ?movie
|
||||
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.
|
||||
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.
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
///
|
||||
/// 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) {
|
||||
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) {
|
||||
dynamic value = json[first];
|
||||
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";
|
||||
}
|
||||
|
||||
class WikidataEntities {
|
||||
static const String film = "Q11424";
|
||||
static const String filmProject = "Q18011172";
|
||||
}
|
||||
|
||||
ApiManager _wikidataApi =
|
||||
ApiManager("https://www.wikidata.org/w/api.php?origin=*");
|
||||
|
||||
|
@ -43,19 +48,26 @@ class WikidataMovieApi implements MovieApi {
|
|||
@override
|
||||
Future<List<WikidataMovieData>> getUpcomingMovies(DateTime startDate,
|
||||
[int count = 100]) async {
|
||||
Response response = await queryApi.get(
|
||||
"&query=${Uri.encodeComponent(_createUpcomingMovieQuery(startDate, count))}");
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
"The Wikidata request for upcoming movies failed with status ${response.statusCode} ${response.reasonPhrase}");
|
||||
Response filmResponse = await queryApi.get(
|
||||
"&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) {
|
||||
throw Exception(
|
||||
"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
|
||||
.map((entry) =>
|
||||
RegExp(r"Q\d+$").firstMatch(entry["movie"]["value"])![0]!)
|
||||
.toList();
|
||||
return _getMovieDataFromIds(ids);
|
||||
return await _getMovieDataFromIds(ids);
|
||||
}
|
||||
|
||||
Future<List<WikidataMovieData>> _getMovieDataFromIds(
|
||||
|
@ -92,9 +104,20 @@ class WikidataMovieApi implements MovieApi {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<List<WikidataMovieData>> searchForMovies(String searchTerm) {
|
||||
// TODO: implement searchForMovies
|
||||
throw UnimplementedError();
|
||||
Future<List<WikidataMovieData>> searchForMovies(String searchTerm) async {
|
||||
String haswbstatement =
|
||||
"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();
|
||||
List<DateWithPrecisionAndCountry> releaseDates =
|
||||
selectInJson(claims, "${WikidataProperties.publicationDate}.*")
|
||||
.where((dateClaim) => dateClaim["rank"] != "deprecated")
|
||||
.map<DateWithPrecisionAndCountry>((dateClaim) {
|
||||
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
|
||||
String country = _getCachedLabelForEntity(selectInJson<String>(dateClaim,
|
||||
|
@ -149,8 +173,13 @@ class WikidataMovieData extends MovieData {
|
|||
claims, "${WikidataProperties.genre}.*.mainsnak.datavalue.value.id")
|
||||
.map(_getCachedLabelForEntity)
|
||||
.toList();
|
||||
WikidataMovieData movie =
|
||||
WikidataMovieData(title, releaseDates[0], entityId);
|
||||
WikidataMovieData movie = WikidataMovieData(
|
||||
title,
|
||||
releaseDates.isNotEmpty
|
||||
? releaseDates[0]
|
||||
: DateWithPrecisionAndCountry(
|
||||
DateTime.now(), DatePrecision.decade, "unknown location"),
|
||||
entityId);
|
||||
movie.setDetails(
|
||||
titles: titles,
|
||||
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);
|
||||
return """
|
||||
SELECT
|
||||
?movie
|
||||
(MIN(?releaseDate) as ?minReleaseDate)
|
||||
WHERE {
|
||||
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film"
|
||||
wdt:P577 ?releaseDate. # P577 is the "publication date" property
|
||||
?movie p:P577/psv:P577 [wikibase:timePrecision ?precision].
|
||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime) && ?precision >= 10)
|
||||
?movie wdt:${WikidataProperties.instanceOf} wd:$instanceOf;
|
||||
wdt:${WikidataProperties.publicationDate} ?releaseDate.
|
||||
?movie p:${WikidataProperties.publicationDate}/psv:${WikidataProperties.publicationDate} [wikibase:timePrecision ?precision].
|
||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
|
||||
FILTER (?precision >= 10)
|
||||
}
|
||||
GROUP BY ?movie
|
||||
ORDER BY ?minReleaseDate
|
||||
|
|
178
lib/main.dart
178
lib/main.dart
|
@ -1,9 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/api/wikidata_movie_api.dart';
|
||||
import 'package:release_schedule/model/live_search.dart';
|
||||
import 'package:release_schedule/model/movie.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/swipe-transition.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
|
@ -27,53 +28,140 @@ class MyApp extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
final MovieApi api = WikidataMovieApi();
|
||||
class HomePage extends StatefulWidget {
|
||||
final MovieManager manager;
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Release Schedule"),
|
||||
actions: [HamburgerMenu(manager)],
|
||||
),
|
||||
body: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
Scaffold(
|
||||
body: MovieManagerList(
|
||||
manager,
|
||||
// Only show movies that are bookmarked or have a release date with at least month precision and at least one title
|
||||
filter: (movie) =>
|
||||
movie.bookmarked ||
|
||||
(movie.releaseDate.precision >= DatePrecision.month &&
|
||||
(movie.titles?.length ?? 0) >= 1),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.refresh),
|
||||
onPressed: () => manager.loadUpcomingMovies(),
|
||||
),
|
||||
),
|
||||
MovieManagerList(
|
||||
manager,
|
||||
filter: (movie) => movie.bookmarked,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const TabBar(tabs: [
|
||||
Tab(icon: Icon(Icons.list), child: Text("Upcoming")),
|
||||
Tab(icon: Icon(Icons.bookmark), child: Text("Bookmarked")),
|
||||
]),
|
||||
],
|
||||
title: TextField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Search",
|
||||
border: InputBorder.none,
|
||||
),
|
||||
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,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
Scaffold(
|
||||
body: MovieManagerList(
|
||||
manager,
|
||||
// Only show movies that are bookmarked or have a release date with at least month precision and at least one title
|
||||
filter: (movie) =>
|
||||
movie.bookmarked ||
|
||||
(movie.releaseDate.precision >= DatePrecision.month &&
|
||||
(movie.titles?.length ?? 0) >= 1),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.refresh),
|
||||
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(
|
||||
manager,
|
||||
filter: (movie) => movie.bookmarked,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const TabBar(tabs: [
|
||||
Tab(icon: Icon(Icons.list), child: Text("Upcoming")),
|
||||
Tab(icon: Icon(Icons.bookmark), child: Text("Bookmarked")),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -89,6 +177,16 @@ class HamburgerMenu extends StatelessWidget {
|
|||
icon: const Icon(Icons.menu),
|
||||
itemBuilder: (context) {
|
||||
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(
|
||||
child: const Text("Remove all not bookmarked"),
|
||||
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/local_movie_storage.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/model/search.dart';
|
||||
|
||||
final movieManager = MovieManager(WikidataMovieApi(),
|
||||
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable));
|
||||
|
@ -15,7 +16,7 @@ class MovieManager extends ChangeNotifier {
|
|||
final LocalMovieStorage cache;
|
||||
final MovieApi api;
|
||||
bool loading = false;
|
||||
DelayedFunctionCaller? cacheUpdater;
|
||||
late final DelayedFunctionCaller cacheUpdater;
|
||||
bool cacheLoaded = false;
|
||||
|
||||
MovieManager(this.api, this.cache) {
|
||||
|
@ -31,7 +32,7 @@ class MovieManager extends ChangeNotifier {
|
|||
}
|
||||
|
||||
void _moviesModified({bool withoutAddingOrRemoving = false}) {
|
||||
cacheUpdater?.call();
|
||||
cacheUpdater.call();
|
||||
if (!withoutAddingOrRemoving) {
|
||||
// only notify listeners if movies are added or removed
|
||||
// if they are modified in place they will notify listeners themselves
|
||||
|
@ -108,10 +109,19 @@ class MovieManager extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// 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.
|
||||
Future<List<MovieData>> search(String search) async {
|
||||
Future<List<MovieData>> onlineSearch(String search) async {
|
||||
List<MovieData> movies = await api.searchForMovies(search);
|
||||
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),
|
||||
subtitle: Text(
|
||||
"${dateRelativeToNow(movie.releaseDate.date)}, ${movie.releaseDate.toString()}, ${movie.genres?.join(", ") ?? ""}"),
|
||||
trailing: TextButton(
|
||||
child: Icon(movie.bookmarked
|
||||
trailing: IconButton(
|
||||
icon: Icon(movie.bookmarked
|
||||
? Icons.bookmark_added
|
||||
: Icons.bookmark_border),
|
||||
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
|
||||
|
|
|
@ -29,9 +29,14 @@ class MoviePage extends StatelessWidget {
|
|||
animation: movie,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(movie.title),
|
||||
),
|
||||
appBar: AppBar(title: Text(movie.title), actions: [
|
||||
IconButton(
|
||||
icon: Icon(movie.bookmarked
|
||||
? Icons.bookmark_added
|
||||
: Icons.bookmark_outline),
|
||||
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
|
||||
),
|
||||
]),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
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());
|
||||
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 {
|
||||
|
|
Loading…
Reference in New Issue