Compare commits
No commits in common. "9aa0278ab062cc378cdd763c8606d938506766a7" and "f9a0482e3c8868cef774d6a98c5d1b06842e716e" have entirely different histories.
9aa0278ab0
...
f9a0482e3c
22
README.md
22
README.md
|
@ -6,18 +6,12 @@ You can try out the live web version at [daniel-michel.github.io/release_schedul
|
|||
|
||||
Android, Linux and Web builds can be found in the latest [CI run](https://github.com/daniel-michel/release_schedule/actions/workflows/ci.yml).
|
||||
|
||||
## Overview
|
||||
Currently, only a simple list of upcoming movies is shown:
|
||||
|
||||
There are two screens that show upcoming movies and bookmarked movies:
|
||||
![](screenshots/movie_list.png)
|
||||
|
||||
<img src="screenshots/upcoming.png" width="300">
|
||||
<img src="screenshots/bookmarks.png" width="300">
|
||||
The floating button at the bottom right can be used to load the upcoming movies and the button at the top right to clear the movies that where already loaded.
|
||||
|
||||
The floating button at the bottom right of the upcoming movies list can be used to load new upcoming movies. The menu at the top right can be used to clear some or all of the cached movies.
|
||||
|
||||
The movies that are cached as well as other movies can be searched with the search field at the top:
|
||||
|
||||
<img src="screenshots/search.png" width="300">
|
||||
|
||||
## Wikidata API
|
||||
|
||||
|
@ -26,27 +20,21 @@ The Implementation can be found at [./lib/api/wikidata_movie_api.dart](./lib/api
|
|||
To get information about the upcoming movies multiple APIs are used.
|
||||
|
||||
First the SPARQL API is used to retrieve upcoming movies using the endpoint "https://query.wikidata.org/sparql" with the following query:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
?movie
|
||||
(MIN(?releaseDate) as ?minReleaseDate)
|
||||
WHERE {
|
||||
?movie wdt:P31 wd:Q18011172;
|
||||
p:P577/psv:P577 [wikibase:timePrecision ?precision];
|
||||
wdt:P577 ?releaseDate.
|
||||
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film"
|
||||
wdt:P577 ?releaseDate. # P577 is the "publication date" property
|
||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
|
||||
FILTER (?precision >= 10)
|
||||
}
|
||||
GROUP BY ?movie
|
||||
ORDER BY ?minReleaseDate
|
||||
LIMIT $limit
|
||||
```
|
||||
|
||||
Where `$limit` is the maximum number of movies that are retrieved and `$date` the starting date from which movies are retrieved.
|
||||
`$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,11 +7,20 @@
|
|||
///
|
||||
/// 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);
|
||||
}
|
||||
|
||||
Map<String, Iterable<dynamic>> selectMultipleInJson(
|
||||
dynamic json, Map<String, String> selector) {
|
||||
Map<String, Iterable<dynamic>> result = {};
|
||||
for (String key in selector.keys) {
|
||||
result[key] = selectInJsonWithPath(json, selector[key]!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Select values in nested [List] and [Map] structures using a path that may contain wildcards.
|
||||
///
|
||||
/// The maps must always use [String] keys.
|
||||
|
@ -31,18 +40,13 @@ Iterable<({T value, String path})> selectInJsonWithPath<T>(
|
|||
List<String> pathParts = path.split(".");
|
||||
String first = pathParts.removeAt(0);
|
||||
String rest = pathParts.join(".");
|
||||
({T value, String path}) addFirstToPath(({T value, String path}) element) {
|
||||
return (
|
||||
value: element.value,
|
||||
path: element.path.isEmpty ? first : "$first.${element.path}"
|
||||
);
|
||||
}
|
||||
addFirstToPath(({T value, String path}) element) => (
|
||||
value: element.value,
|
||||
path: element.path.isEmpty ? first : "$first.${element.path}"
|
||||
);
|
||||
|
||||
if (first == "*" || first == "**") {
|
||||
String continueWithPath = first == "*" ? rest : path;
|
||||
if (first == "**") {
|
||||
yield* selectInJsonWithPath<T>(json, rest);
|
||||
}
|
||||
if (json is List) {
|
||||
yield* json
|
||||
.expand((e) => selectInJsonWithPath<T>(e, continueWithPath))
|
||||
|
@ -63,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, rest);
|
||||
yield* selectInJsonWithPath<T>(value, pathParts.join("."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,6 @@ class MovieApi {
|
|||
[];
|
||||
|
||||
Future<List<MovieData>> searchForMovies(String searchTerm) async => [];
|
||||
|
||||
Future<void> addMovieDetails(List<MovieData> movies) async {}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import 'package:intl/intl.dart';
|
|||
import 'package:release_schedule/api/api_manager.dart';
|
||||
import 'package:release_schedule/api/json_helper.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
class WikidataProperties {
|
||||
|
@ -26,12 +25,6 @@ class WikidataProperties {
|
|||
static const String reviewScore = "P444";
|
||||
static const String fskFilmRating = "P1981";
|
||||
static const String placeOfPublication = "P291";
|
||||
static const String shortName = "P1813";
|
||||
}
|
||||
|
||||
class WikidataEntities {
|
||||
static const String film = "Q11424";
|
||||
static const String filmProject = "Q18011172";
|
||||
}
|
||||
|
||||
ApiManager _wikidataApi =
|
||||
|
@ -41,29 +34,28 @@ class WikidataMovieApi implements MovieApi {
|
|||
ApiManager queryApi =
|
||||
ApiManager("https://query.wikidata.org/sparql?format=json&origin=*");
|
||||
|
||||
@override
|
||||
Future<void> addMovieDetails(List<MovieData> movies) {
|
||||
// TODO: implement addMovieDetails
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WikidataMovieData>> getUpcomingMovies(DateTime startDate,
|
||||
[int count = 100]) async {
|
||||
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}");
|
||||
}
|
||||
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}");
|
||||
}
|
||||
Iterable<Map<String, dynamic>> results =
|
||||
responses.map((response) => jsonDecode(response.body));
|
||||
Iterable<dynamic> entries =
|
||||
results.expand((result) => result["results"]["bindings"]);
|
||||
Map<String, dynamic> result = jsonDecode(response.body);
|
||||
List<dynamic> entries = result["results"]["bindings"];
|
||||
List<String> ids = entries
|
||||
.map((entry) =>
|
||||
RegExp(r"Q\d+$").firstMatch(entry["movie"]["value"])![0]!)
|
||||
.toList();
|
||||
return await _getMovieDataFromIds(ids);
|
||||
return _getMovieDataFromIds(ids);
|
||||
}
|
||||
|
||||
Future<List<WikidataMovieData>> _getMovieDataFromIds(
|
||||
|
@ -100,20 +92,9 @@ class WikidataMovieApi implements MovieApi {
|
|||
}
|
||||
|
||||
@override
|
||||
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);
|
||||
Future<List<WikidataMovieData>> searchForMovies(String searchTerm) {
|
||||
// TODO: implement searchForMovies
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -151,62 +132,43 @@ class WikidataMovieData extends MovieData {
|
|||
) as TitleInLanguage)
|
||||
.toList();
|
||||
List<DateWithPrecisionAndCountry> releaseDates =
|
||||
_getReleaseDates(claims).toList();
|
||||
selectInJson(claims, "${WikidataProperties.publicationDate}.*")
|
||||
.map<DateWithPrecisionAndCountry>((dateClaim) {
|
||||
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
|
||||
String country = _getCachedLabelForEntity(selectInJson<String>(dateClaim,
|
||||
"qualifiers.${WikidataProperties.placeOfPublication}.*.datavalue.value.id")
|
||||
.firstOrNull ??
|
||||
"no country");
|
||||
return DateWithPrecisionAndCountry(DateTime.parse(value["time"]),
|
||||
_precisionFromWikidata(value["precision"]), country);
|
||||
}).toList();
|
||||
// Sort release dates with higher precision to the beginning
|
||||
releaseDates.sort((a, b) => -a.dateWithPrecision.precision.index
|
||||
.compareTo(b.dateWithPrecision.precision.index));
|
||||
releaseDates
|
||||
.sort((a, b) => -a.precision.index.compareTo(b.precision.index));
|
||||
List<String>? genres = selectInJson<String>(
|
||||
claims, "${WikidataProperties.genre}.*.mainsnak.datavalue.value.id")
|
||||
.map(_getCachedLabelForEntity)
|
||||
.toList();
|
||||
WikidataMovieData movie = WikidataMovieData(
|
||||
title,
|
||||
releaseDates.isNotEmpty
|
||||
? releaseDates[0]
|
||||
: DateWithPrecisionAndCountry(
|
||||
DateTime.now(), DatePrecision.decade, "unknown location"),
|
||||
entityId);
|
||||
WikidataMovieData movie =
|
||||
WikidataMovieData(title, releaseDates[0], entityId);
|
||||
movie.setDetails(
|
||||
titles: titles,
|
||||
releaseDates: releaseDates,
|
||||
genres: genres,
|
||||
);
|
||||
return movie;
|
||||
}
|
||||
|
||||
static Iterable<DateWithPrecisionAndCountry> _getReleaseDates(
|
||||
Map<String, dynamic> claims) {
|
||||
return selectInJson(claims, "${WikidataProperties.publicationDate}.*")
|
||||
.where((dateClaim) => dateClaim["rank"] != "deprecated")
|
||||
.expand<DateWithPrecisionAndCountry>((dateClaim) {
|
||||
var value = selectInJson(dateClaim, "mainsnak.datavalue.value").first;
|
||||
Iterable<String> countries = (selectInJson<String>(dateClaim,
|
||||
"qualifiers.${WikidataProperties.placeOfPublication}.*.datavalue.value.id"))
|
||||
.map(_getCachedLabelForEntity);
|
||||
if (countries.isEmpty) {
|
||||
countries = ["unknown location"];
|
||||
}
|
||||
return countries.map((country) => DateWithPrecisionAndCountry(
|
||||
DateTime.parse(value["time"]),
|
||||
_precisionFromWikidata(value["precision"]),
|
||||
country));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _createUpcomingMovieQuery(
|
||||
DateTime startDate, String instanceOf, int limit) {
|
||||
String _createUpcomingMovieQuery(DateTime startDate, int limit) {
|
||||
String date = DateFormat("yyyy-MM-dd").format(startDate);
|
||||
return """
|
||||
SELECT
|
||||
?movie
|
||||
(MIN(?releaseDate) as ?minReleaseDate)
|
||||
WHERE {
|
||||
?movie wdt:${WikidataProperties.instanceOf} wd:$instanceOf;
|
||||
wdt:${WikidataProperties.publicationDate} ?releaseDate.
|
||||
?movie p:${WikidataProperties.publicationDate}/psv:${WikidataProperties.publicationDate} [wikibase:timePrecision ?precision].
|
||||
?movie wdt:P31 wd:Q11424; # Q11424 is the item for "film"
|
||||
wdt:P577 ?releaseDate. # P577 is the "publication date" property
|
||||
FILTER (xsd:date(?releaseDate) >= xsd:date("$date"^^xsd:dateTime))
|
||||
FILTER (?precision >= 10)
|
||||
}
|
||||
GROUP BY ?movie
|
||||
ORDER BY ?minReleaseDate
|
||||
|
@ -241,24 +203,16 @@ Future<Map<String, String>> _getLabelsForEntities(
|
|||
final start = i * batchSize;
|
||||
final end = min((i + 1) * batchSize, entityIds.length);
|
||||
Response response = await _wikidataApi.get(
|
||||
"&action=wbgetentities&format=json&props=labels|claims&ids=${entityIds.sublist(start, end).join("|")}");
|
||||
"&action=wbgetentities&format=json&props=labels&ids=${entityIds.sublist(start, end).join("|")}");
|
||||
Map<String, dynamic> result = jsonDecode(response.body);
|
||||
Map<String, dynamic> batchEntities = result["entities"];
|
||||
for (String entityId in batchEntities.keys) {
|
||||
String? shortName = selectInJson(batchEntities[entityId],
|
||||
"claims.${WikidataProperties.shortName}.*.mainsnak.datavalue.value")
|
||||
.where((value) => value["language"] == "en")
|
||||
.map((value) => (value["text"] as String))
|
||||
.firstOrNull;
|
||||
Map<String, dynamic> responseLabels = batchEntities[entityId]["labels"];
|
||||
if (shortName != null) {
|
||||
_labelCache[entityId] = labels[entityId] = shortName;
|
||||
continue;
|
||||
}
|
||||
String label = responseLabels.containsKey("en")
|
||||
? responseLabels["en"]["value"]
|
||||
: responseLabels[responseLabels.keys.first]["value"];
|
||||
_labelCache[entityId] = labels[entityId] = label;
|
||||
Map<String, dynamic> labels = batchEntities[entityId]["labels"];
|
||||
String label = labels.containsKey("en")
|
||||
? labels["en"]["value"]
|
||||
: labels[labels.keys.first]["value"];
|
||||
labels[entityId] = label;
|
||||
_labelCache[entityId] = label;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
|
|
238
lib/main.dart
238
lib/main.dart
|
@ -1,12 +1,8 @@
|
|||
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/dates.dart';
|
||||
import 'package:release_schedule/model/live_search.dart';
|
||||
import 'package:release_schedule/model/local_movie_storage.dart';
|
||||
import 'package:release_schedule/model/movie_manager.dart';
|
||||
import 'package:release_schedule/view/movie_item.dart';
|
||||
import 'package:release_schedule/view/movie_manager_list.dart';
|
||||
import 'package:release_schedule/view/swipe_transition.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
|
@ -20,238 +16,38 @@ class MyApp extends StatelessWidget {
|
|||
return MaterialApp(
|
||||
title: 'Movie Schedule',
|
||||
themeMode: ThemeMode.dark,
|
||||
darkTheme: ThemeData.dark(
|
||||
useMaterial3: true,
|
||||
).copyWith(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.deepOrange,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData.dark(useMaterial3: true),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: HomePage(
|
||||
MovieManager(WikidataMovieApi(),
|
||||
LocalMovieStorageGetStorage(WikidataMovieData.fromEncodable)),
|
||||
),
|
||||
home: HomePage(movieManager),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
class HomePage extends StatelessWidget {
|
||||
final MovieApi api = WikidataMovieApi();
|
||||
final MovieManager manager;
|
||||
|
||||
const HomePage(this.manager, {super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late LiveSearch liveSearch;
|
||||
late TextEditingController _searchController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this, // the SingleTickerProviderStateMixin
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
_searchController = TextEditingController();
|
||||
liveSearch = LiveSearch(widget.manager);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
HomePage(this.manager, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 1,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Search",
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value.isEmpty) {
|
||||
_controller.reverse();
|
||||
} else {
|
||||
_controller.forward();
|
||||
}
|
||||
liveSearch.updateSearch(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
if (liveSearch.searchTerm.isEmpty) return Container();
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
liveSearch.updateSearch("");
|
||||
_controller.reverse();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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 Column(
|
||||
children: [
|
||||
liveSearch.loading ? const LinearProgressIndicator() : Container(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: liveSearch.searchResults.length,
|
||||
itemBuilder: (context, index) => MovieItem(
|
||||
liveSearch.searchResults[index],
|
||||
showReleaseDate: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Icons.list), child: Text("Upcoming")),
|
||||
Tab(icon: Icon(Icons.bookmark), child: Text("Bookmarked")),
|
||||
],
|
||||
),
|
||||
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.dateWithPrecision.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,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
title: const Text("Release Schedule"),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => manager.removeMoviesWhere((movie) => true),
|
||||
child: const Icon(Icons.delete))
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HamburgerMenu extends StatelessWidget {
|
||||
final MovieManager manager;
|
||||
const HamburgerMenu(this.manager, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton(
|
||||
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.dateWithPrecision.precision >=
|
||||
DatePrecision.month &&
|
||||
date.dateWithPrecision.date.isAfter(DateTime.now()
|
||||
.subtract(const Duration(days: 30)))) ??
|
||||
false)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: const Text("Remove all not bookmarked"),
|
||||
onTap: () =>
|
||||
manager.removeMoviesWhere((movie) => !movie.bookmarked),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: const Text("Remove all"),
|
||||
onTap: () => manager.removeMoviesWhere((movie) => true),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: MovieManagerList(manager),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.refresh),
|
||||
onPressed: () => manager.loadUpcomingMovies(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/// Compares dates relative to each other. Times are ignored.
|
||||
String dateRelativeToNow(DateTime date) {
|
||||
DateTime dateOnly = DateTime.utc(date.year, date.month, date.day);
|
||||
DateTime now = DateTime.now().toUtc();
|
||||
DateTime today = DateTime.utc(now.year, now.month, now.day);
|
||||
Duration diff = dateOnly.difference(today);
|
||||
return _durationToRelativeDateString(diff);
|
||||
}
|
||||
|
||||
String _durationToRelativeDateString(Duration duration) {
|
||||
if (duration == const Duration(days: 1)) {
|
||||
return "Tomorrow";
|
||||
} else if (duration == const Duration(days: -1)) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (duration.isNegative) {
|
||||
String result = _durationApproximatedInWords(-duration);
|
||||
return "${result[0].toUpperCase()}${result.substring(1)} ago";
|
||||
} else if (duration == Duration.zero) {
|
||||
return "Today";
|
||||
} else {
|
||||
return "In ${_durationApproximatedInWords(duration)}";
|
||||
}
|
||||
}
|
||||
|
||||
String _durationApproximatedInWords(Duration duration) {
|
||||
int days = duration.inDays;
|
||||
int weeks = (days / 7).floor();
|
||||
int months = (days / 30).floor();
|
||||
int years = (days / 365).floor();
|
||||
int centuries = (years / 100).floor();
|
||||
if (duration == Duration.zero) {
|
||||
return "now";
|
||||
}
|
||||
if (days < 7) {
|
||||
return days > 1 ? "$days days" : "a day";
|
||||
}
|
||||
if (months == 0) {
|
||||
return weeks > 1 ? "$weeks weeks" : "a week";
|
||||
}
|
||||
if (years == 0) {
|
||||
return months > 1 ? "$months months" : "a month";
|
||||
}
|
||||
if (years < 100) {
|
||||
return years > 1 ? "$years years" : "a year";
|
||||
}
|
||||
return centuries > 1 ? "$centuries centuries" : "a century";
|
||||
}
|
|
@ -1,124 +0,0 @@
|
|||
import 'package:intl/intl.dart';
|
||||
|
||||
DateTime getToday() {
|
||||
DateTime now = DateTime.now().toUtc();
|
||||
return DateTime.utc(now.year, now.month, now.day);
|
||||
}
|
||||
|
||||
enum DatePrecision { decade, year, month, day, hour, minute }
|
||||
|
||||
extension DatePrecisionComparison on DatePrecision {
|
||||
bool operator <(DatePrecision other) {
|
||||
return index < other.index;
|
||||
}
|
||||
|
||||
bool operator <=(DatePrecision other) {
|
||||
return index <= other.index;
|
||||
}
|
||||
|
||||
bool operator >(DatePrecision other) {
|
||||
return index > other.index;
|
||||
}
|
||||
|
||||
bool operator >=(DatePrecision other) {
|
||||
return index >= other.index;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime simplifyDateToPrecision(DateTime date, DatePrecision precision) {
|
||||
switch (precision) {
|
||||
case DatePrecision.decade:
|
||||
return DateTime(date.year ~/ 10 * 10);
|
||||
case DatePrecision.year:
|
||||
return DateTime(date.year);
|
||||
case DatePrecision.month:
|
||||
return DateTime(date.year, date.month);
|
||||
case DatePrecision.day:
|
||||
return DateTime(date.year, date.month, date.day);
|
||||
case DatePrecision.hour:
|
||||
return DateTime(date.year, date.month, date.day, date.hour);
|
||||
case DatePrecision.minute:
|
||||
return DateTime(date.year, date.month, date.day, date.hour, date.minute);
|
||||
}
|
||||
}
|
||||
|
||||
class DateWithPrecision implements Comparable<DateWithPrecision> {
|
||||
DateTime date;
|
||||
DatePrecision precision;
|
||||
|
||||
DateWithPrecision(DateTime date, this.precision)
|
||||
: date = simplifyDateToPrecision(date, precision);
|
||||
|
||||
DateWithPrecision.fromJsonEncodable(List<dynamic> json)
|
||||
: date = DateTime.parse(json[0]),
|
||||
precision = DatePrecision.values
|
||||
.firstWhere((element) => element.name == json[1]);
|
||||
|
||||
DateWithPrecision.today() : this(DateTime.now().toUtc(), DatePrecision.day);
|
||||
|
||||
List<dynamic> toJsonEncodable() {
|
||||
return [date.toIso8601String(), precision.name];
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return switch (precision) {
|
||||
DatePrecision.decade =>
|
||||
"${DateFormat("yyyy").format(date).substring(0, 3)}0s",
|
||||
DatePrecision.year => DateFormat.y().format(date),
|
||||
DatePrecision.month => DateFormat.yMMMM().format(date),
|
||||
DatePrecision.day => DateFormat.yMMMMd().format(date),
|
||||
DatePrecision.hour => DateFormat("MMMM d, yyyy, HH").format(date),
|
||||
DatePrecision.minute => DateFormat("MMMM d, yyyy, HH:mm").format(date)
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
int compareTo(DateWithPrecision other) {
|
||||
if (date.isBefore(other.date)) {
|
||||
return -1;
|
||||
} else if (date.isAfter(other.date)) {
|
||||
return 1;
|
||||
} else {
|
||||
return precision.index - other.precision.index;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is DateWithPrecision &&
|
||||
date == other.date &&
|
||||
precision == other.precision;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return date.hashCode ^ precision.hashCode;
|
||||
}
|
||||
|
||||
bool includes(DateTime date) {
|
||||
switch (precision) {
|
||||
case DatePrecision.decade:
|
||||
return this.date.year ~/ 10 == date.year ~/ 10;
|
||||
case DatePrecision.year:
|
||||
return this.date.year == date.year;
|
||||
case DatePrecision.month:
|
||||
return this.date.year == date.year && this.date.month == date.month;
|
||||
case DatePrecision.day:
|
||||
return this.date.year == date.year &&
|
||||
this.date.month == date.month &&
|
||||
this.date.day == date.day;
|
||||
case DatePrecision.hour:
|
||||
return this.date.year == date.year &&
|
||||
this.date.month == date.month &&
|
||||
this.date.day == date.day &&
|
||||
this.date.hour == date.hour;
|
||||
case DatePrecision.minute:
|
||||
return this.date.year == date.year &&
|
||||
this.date.month == date.month &&
|
||||
this.date.day == date.day &&
|
||||
this.date.hour == date.hour &&
|
||||
this.date.minute == date.minute;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +1,21 @@
|
|||
import 'dart:async';
|
||||
|
||||
class DelayedFunctionCaller {
|
||||
final void Function() function;
|
||||
final Function function;
|
||||
final Duration duration;
|
||||
final bool resetTimerOnCall;
|
||||
Timer? _timer;
|
||||
|
||||
DelayedFunctionCaller(this.function, this.duration,
|
||||
{this.resetTimerOnCall = false});
|
||||
|
||||
get scheduled => _timer != null && _timer!.isActive;
|
||||
DelayedFunctionCaller(this.function, this.duration);
|
||||
|
||||
void call() {
|
||||
// If a timer is already active, return.
|
||||
if (_timer != null && _timer!.isActive) {
|
||||
// If a timer is already active and we don't want to reset it, return.
|
||||
if (!resetTimerOnCall) {
|
||||
return;
|
||||
}
|
||||
_timer!.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a timer that calls the function after the specified duration.
|
||||
_timer = Timer(duration, function);
|
||||
_timer = Timer(duration, () {
|
||||
function();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,60 +0,0 @@
|
|||
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 = [];
|
||||
late final DelayedFunctionCaller _searchCaller;
|
||||
final MovieManager manager;
|
||||
bool searchingOnline = false;
|
||||
|
||||
LiveSearch(this.manager) {
|
||||
_searchCaller = DelayedFunctionCaller(
|
||||
searchOnline,
|
||||
const Duration(milliseconds: 750),
|
||||
resetTimerOnCall: true,
|
||||
);
|
||||
}
|
||||
|
||||
get loading => searchingOnline || _searchCaller.scheduled;
|
||||
|
||||
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();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
searchingOnline = true;
|
||||
try {
|
||||
String startedSearching = searchTerm;
|
||||
List<MovieData> onlineResults = await manager.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;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,25 +1,18 @@
|
|||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
abstract class LocalMovieStorage {
|
||||
void update(List<MovieData> movies);
|
||||
Future<List<MovieData>> retrieve();
|
||||
}
|
||||
|
||||
class InMemoryMovieStorage implements LocalMovieStorage {
|
||||
class LocalMovieStorage {
|
||||
List<MovieData> _storedMovies = [];
|
||||
@override
|
||||
update(List<MovieData> movies) {
|
||||
_storedMovies = movies;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MovieData>> retrieve() async {
|
||||
return _storedMovies;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalMovieStorageGetStorage implements LocalMovieStorage {
|
||||
class LocalMovieStorageGetStorage extends LocalMovieStorage {
|
||||
Future<void>? initialized;
|
||||
GetStorage? container;
|
||||
MovieData Function(Map jsonEncodable) toMovieData;
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class MovieData extends ChangeNotifier {
|
||||
String _title;
|
||||
DateWithPrecisionAndCountry _releaseDate;
|
||||
bool _bookmarked = false;
|
||||
|
||||
bool _hasDetails = false;
|
||||
List<DateWithPrecisionAndCountry>? _releaseDates;
|
||||
List<String>? _genres;
|
||||
List<TitleInLanguage>? _titles;
|
||||
List<Review>? _reviews;
|
||||
|
||||
MovieData(this._title, this._releaseDate);
|
||||
|
||||
|
@ -21,10 +21,6 @@ class MovieData extends ChangeNotifier {
|
|||
return _releaseDate;
|
||||
}
|
||||
|
||||
bool get bookmarked {
|
||||
return _bookmarked;
|
||||
}
|
||||
|
||||
List<DateWithPrecisionAndCountry>? get releaseDates {
|
||||
return _releaseDates;
|
||||
}
|
||||
|
@ -37,37 +33,37 @@ class MovieData extends ChangeNotifier {
|
|||
return _titles;
|
||||
}
|
||||
|
||||
List<Review>? get reviews {
|
||||
return _reviews;
|
||||
}
|
||||
|
||||
bool get hasDetails {
|
||||
return _hasDetails;
|
||||
}
|
||||
|
||||
/// Updates the information with that of a new version of the movie
|
||||
/// but ignores fields that are user controlled, like whether the movie was bookmarked.
|
||||
void updateWithNewIgnoringUserControlled(MovieData movie) {
|
||||
void updateWithNew(MovieData movie) {
|
||||
setDetails(
|
||||
title: movie.title,
|
||||
releaseDate: movie.releaseDate,
|
||||
releaseDates: movie.releaseDates,
|
||||
genres: movie.genres,
|
||||
titles: movie.titles);
|
||||
titles: movie.titles,
|
||||
reviews: movie.reviews);
|
||||
}
|
||||
|
||||
void setDetails(
|
||||
{String? title,
|
||||
DateWithPrecisionAndCountry? releaseDate,
|
||||
bool? bookmarked,
|
||||
List<DateWithPrecisionAndCountry>? releaseDates,
|
||||
List<String>? genres,
|
||||
List<TitleInLanguage>? titles}) {
|
||||
List<TitleInLanguage>? titles,
|
||||
List<Review>? reviews}) {
|
||||
if (title != null) {
|
||||
_title = title;
|
||||
}
|
||||
if (releaseDate != null) {
|
||||
_releaseDate = releaseDate;
|
||||
}
|
||||
if (bookmarked != null) {
|
||||
_bookmarked = bookmarked;
|
||||
}
|
||||
if (releaseDates != null) {
|
||||
_releaseDates = releaseDates;
|
||||
}
|
||||
|
@ -77,18 +73,16 @@ class MovieData extends ChangeNotifier {
|
|||
if (titles != null) {
|
||||
_titles = titles;
|
||||
}
|
||||
if (reviews != null) {
|
||||
_reviews = reviews;
|
||||
}
|
||||
_hasDetails = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$title (${_releaseDate.toString()}${_genres?.isNotEmpty ?? false ? "; ${_genres?.join(", ")}" : ""})";
|
||||
}
|
||||
|
||||
bool same(MovieData other) {
|
||||
return title == other.title &&
|
||||
releaseDate.dateWithPrecision == other.releaseDate.dateWithPrecision;
|
||||
return "$title (${_releaseDate.toString()}${_genres?.isNotEmpty ?? true ? "; ${_genres?.join(", ")}" : ""})";
|
||||
}
|
||||
|
||||
Map toJsonEncodable() {
|
||||
|
@ -98,19 +92,22 @@ class MovieData extends ChangeNotifier {
|
|||
return {
|
||||
"title": title,
|
||||
"releaseDate": _releaseDate.toJsonEncodable(),
|
||||
"bookmarked": _bookmarked,
|
||||
"releaseDates": releaseDatesByCountry,
|
||||
"genres": genres,
|
||||
"titles": titlesByCountry,
|
||||
"reviews": reviews?.map((review) => review.toJsonEncodable()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
bool same(MovieData other) {
|
||||
return title == other.title && releaseDate.date == other.releaseDate.date;
|
||||
}
|
||||
|
||||
MovieData.fromJsonEncodable(Map json)
|
||||
: _title = json["title"],
|
||||
_releaseDate =
|
||||
DateWithPrecisionAndCountry.fromJsonEncodable(json["releaseDate"]) {
|
||||
setDetails(
|
||||
bookmarked: json["bookmarked"] as bool,
|
||||
genres: (json["genres"] as List<dynamic>?)
|
||||
?.map((genre) => genre as String)
|
||||
.toList(),
|
||||
|
@ -120,6 +117,11 @@ class MovieData extends ChangeNotifier {
|
|||
DateWithPrecisionAndCountry.fromJsonEncodable(release))
|
||||
.toList()
|
||||
: null,
|
||||
reviews: json["reviews"] != null
|
||||
? (json["reviews"] as List<dynamic>)
|
||||
.map((review) => Review.fromJsonEncodable(review))
|
||||
.toList()
|
||||
: null,
|
||||
titles: json["titles"] != null
|
||||
? (json["titles"] as List<dynamic>)
|
||||
.map((title) =>
|
||||
|
@ -129,26 +131,61 @@ class MovieData extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
enum DatePrecision { decade, year, month, day, hour, minute }
|
||||
|
||||
typedef TitleInLanguage = ({String title, String language});
|
||||
|
||||
class DateWithPrecisionAndCountry {
|
||||
final DateWithPrecision dateWithPrecision;
|
||||
final String country;
|
||||
DateTime date;
|
||||
DatePrecision precision;
|
||||
String country;
|
||||
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime date, DatePrecision precision, this.country)
|
||||
: dateWithPrecision = DateWithPrecision(date, precision);
|
||||
DateWithPrecisionAndCountry(this.date, this.precision, this.country);
|
||||
|
||||
DateWithPrecisionAndCountry.fromJsonEncodable(List<dynamic> json)
|
||||
: dateWithPrecision = DateWithPrecision.fromJsonEncodable(json),
|
||||
: date = DateTime.parse(json[0]),
|
||||
precision = DatePrecision.values
|
||||
.firstWhere((element) => element.name == json[1]),
|
||||
country = json[2];
|
||||
|
||||
toJsonEncodable() {
|
||||
return dateWithPrecision.toJsonEncodable() + [country];
|
||||
return [date.toIso8601String(), precision.name, country];
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "${dateWithPrecision.toString()} ($country)";
|
||||
String dateString = switch (precision) {
|
||||
DatePrecision.decade =>
|
||||
"${DateFormat("yyyy").format(date).substring(0, 3)}0s",
|
||||
DatePrecision.year => date.year.toString(),
|
||||
DatePrecision.month => DateFormat("MMMM yyyy").format(date),
|
||||
DatePrecision.day => DateFormat("MMMM d, yyyy").format(date),
|
||||
DatePrecision.hour => DateFormat("MMMM d, yyyy, HH").format(date),
|
||||
DatePrecision.minute => DateFormat("MMMM d, yyyy, HH:mm").format(date)
|
||||
};
|
||||
return "$dateString ($country)";
|
||||
}
|
||||
}
|
||||
|
||||
class Review {
|
||||
String score;
|
||||
String by;
|
||||
DateTime asOf;
|
||||
int count;
|
||||
|
||||
Review(this.score, this.by, this.asOf, this.count);
|
||||
Review.fromJsonEncodable(Map json)
|
||||
: score = json["score"],
|
||||
by = json["by"],
|
||||
asOf = DateTime.parse(json["asOf"]),
|
||||
count = json["count"];
|
||||
|
||||
Map toJsonEncodable() {
|
||||
return {
|
||||
"score": score,
|
||||
"by": by,
|
||||
"asOf": asOf.toIso8601String(),
|
||||
"count": count,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,17 +2,20 @@ import 'dart:async';
|
|||
|
||||
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/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));
|
||||
|
||||
class MovieManager extends ChangeNotifier {
|
||||
final List<MovieData> movies = List.empty(growable: true);
|
||||
final LocalMovieStorage cache;
|
||||
final MovieApi api;
|
||||
bool loading = false;
|
||||
late final DelayedFunctionCaller cacheUpdater;
|
||||
DelayedFunctionCaller? cacheUpdater;
|
||||
bool cacheLoaded = false;
|
||||
|
||||
MovieManager(this.api, this.cache) {
|
||||
|
@ -28,7 +31,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
|
||||
|
@ -41,7 +44,7 @@ class MovieManager extends ChangeNotifier {
|
|||
bool added = false;
|
||||
for (var movie in additionalMovies) {
|
||||
MovieData? existing =
|
||||
movies.where((element) => movie.same(element)).firstOrNull;
|
||||
firstWhereOrNull(movies, (element) => movie.same(element));
|
||||
if (existing == null) {
|
||||
_insertMovie(movie);
|
||||
movie.addListener(() {
|
||||
|
@ -51,7 +54,7 @@ class MovieManager extends ChangeNotifier {
|
|||
added = true;
|
||||
actualMovies.add(movie);
|
||||
} else {
|
||||
existing.updateWithNewIgnoringUserControlled(movie);
|
||||
existing.updateWithNew(movie);
|
||||
actualMovies.add(existing);
|
||||
}
|
||||
}
|
||||
|
@ -64,10 +67,10 @@ class MovieManager extends ChangeNotifier {
|
|||
void _insertMovie(MovieData movie) {
|
||||
int min = 0;
|
||||
int max = movies.length - 1;
|
||||
while (min <= max) {
|
||||
int center = (min + max) ~/ 2;
|
||||
int diff = movie.releaseDate.dateWithPrecision
|
||||
.compareTo(movies[center].releaseDate.dateWithPrecision);
|
||||
while (min - 1 < max) {
|
||||
int center = ((min + max) / 2).floor();
|
||||
int diff =
|
||||
movie.releaseDate.date.compareTo(movies[center].releaseDate.date);
|
||||
if (diff < 0) {
|
||||
max = center - 1;
|
||||
} else {
|
||||
|
@ -82,12 +85,7 @@ class MovieManager extends ChangeNotifier {
|
|||
var temp = movies[i];
|
||||
int j = i - 1;
|
||||
for (;
|
||||
j >= 0 &&
|
||||
movies[j]
|
||||
.releaseDate
|
||||
.dateWithPrecision
|
||||
.compareTo(temp.releaseDate.dateWithPrecision) >
|
||||
0;
|
||||
j >= 0 && movies[j].releaseDate.date.isAfter(temp.releaseDate.date);
|
||||
j--) {
|
||||
movies[j + 1] = movies[j];
|
||||
}
|
||||
|
@ -110,23 +108,18 @@ class MovieManager extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// Only search locally cached movies.
|
||||
List<MovieData> localSearch(String search) {
|
||||
var results = searchList(
|
||||
movies,
|
||||
search,
|
||||
(movie) => [
|
||||
movie.title,
|
||||
...(movie.titles?.map((title) => title.title) ?? []),
|
||||
]);
|
||||
return results;
|
||||
}
|
||||
localSearch(String search) {}
|
||||
|
||||
/// Online search for movies.
|
||||
Future<List<MovieData>> onlineSearch(String search) async {
|
||||
Future<List<MovieData>> search(String search) async {
|
||||
List<MovieData> movies = await api.searchForMovies(search);
|
||||
return addMovies(movies);
|
||||
}
|
||||
|
||||
void expandDetails(List<MovieData> movies) {
|
||||
api.addMovieDetails(movies);
|
||||
}
|
||||
|
||||
Future<void> loadUpcomingMovies() async {
|
||||
try {
|
||||
loading = true;
|
||||
|
@ -140,3 +133,11 @@ class MovieManager extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
|
||||
try {
|
||||
return list.firstWhere(test);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
import 'dart:math';
|
||||
|
||||
class Scored<T> {
|
||||
T data;
|
||||
double score;
|
||||
Scored(this.data, this.score);
|
||||
|
||||
@override
|
||||
toString() => '$data: $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;
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/model/date_format.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/view/movie_page.dart';
|
||||
|
||||
class MovieItem extends StatelessWidget {
|
||||
final MovieData movie;
|
||||
final bool showReleaseDate;
|
||||
const MovieItem(this.movie, {this.showReleaseDate = false, super.key});
|
||||
const MovieItem(this.movie, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -13,28 +12,9 @@ class MovieItem extends StatelessWidget {
|
|||
animation: movie,
|
||||
builder: (context, widget) {
|
||||
return ListTile(
|
||||
title: Text(movie.title),
|
||||
subtitle: Text(
|
||||
(showReleaseDate ? "${movie.releaseDate} " : "") +
|
||||
(movie.genres?.join(", ") ?? ""),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(movie.bookmarked
|
||||
? Icons.bookmark_added
|
||||
: Icons.bookmark_border),
|
||||
onPressed: () => movie.setDetails(bookmarked: !movie.bookmarked),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return MoviePage(movie);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
title: Text(movie.title),
|
||||
subtitle: Text(
|
||||
"${dateRelativeToNow(movie.releaseDate.date)}, ${movie.releaseDate.toString()}, ${movie.genres?.join(", ") ?? ""}"));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,186 +1,18 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/view/movie_item.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class MovieList extends StatelessWidget {
|
||||
final List<MovieData> movies;
|
||||
final bool Function(MovieData)? filter;
|
||||
const MovieList(this.movies, {this.filter, super.key});
|
||||
const MovieList(this.movies, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget noMovies() {
|
||||
return Center(
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.close,
|
||||
size: 100,
|
||||
),
|
||||
Text(
|
||||
"No Movies",
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (movies.isEmpty) {
|
||||
return noMovies();
|
||||
}
|
||||
|
||||
Widget buildGroupSeparator(BuildContext context, DateWithPrecision date) {
|
||||
bool highlight = date.includes(DateTime.now());
|
||||
return SizedBox(
|
||||
height: 50,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Card(
|
||||
elevation: 3,
|
||||
color: highlight
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
date.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final localFilter = filter;
|
||||
if (localFilter != null) {
|
||||
List<int> indexMap = [];
|
||||
int index = 0;
|
||||
for (var movie in movies) {
|
||||
if (localFilter(movie)) {
|
||||
indexMap.add(index);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
if (indexMap.isEmpty) {
|
||||
return noMovies();
|
||||
}
|
||||
int firstMovieTodayOrAfterIndex = () {
|
||||
DateWithPrecision today = DateWithPrecision.today();
|
||||
int min = 0;
|
||||
int max = indexMap.length;
|
||||
while (min < max) {
|
||||
int center = (min + max) ~/ 2;
|
||||
DateWithPrecision date =
|
||||
movies[indexMap[center]].releaseDate.dateWithPrecision;
|
||||
if (date.compareTo(today) < 0) {
|
||||
min = center + 1;
|
||||
} else {
|
||||
max = center;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}();
|
||||
return GroupedList<DateWithPrecision>(
|
||||
itemCount: indexMap.length,
|
||||
groupBy: (index) =>
|
||||
movies[indexMap[index]].releaseDate.dateWithPrecision,
|
||||
groupSeparatorBuilder: (date) => buildGroupSeparator(context, date),
|
||||
itemBuilder: (context, index) {
|
||||
return MovieItem(movies[indexMap[index]]);
|
||||
},
|
||||
initialScrollIndex: firstMovieTodayOrAfterIndex,
|
||||
);
|
||||
}
|
||||
|
||||
int firstMovieTodayOrAfterIndex = () {
|
||||
DateWithPrecision today = DateWithPrecision.today();
|
||||
int min = 0;
|
||||
int max = movies.length;
|
||||
while (min < max) {
|
||||
int center = (min + max) ~/ 2;
|
||||
DateWithPrecision date = movies[center].releaseDate.dateWithPrecision;
|
||||
if (date.compareTo(today) < 0) {
|
||||
min = center + 1;
|
||||
} else {
|
||||
max = center;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}();
|
||||
return GroupedList<DateWithPrecision>(
|
||||
Widget build(Object context) {
|
||||
return ListView.builder(
|
||||
itemCount: movies.length,
|
||||
groupBy: (index) => movies[index].releaseDate.dateWithPrecision,
|
||||
groupSeparatorBuilder: (date) => buildGroupSeparator(context, date),
|
||||
itemBuilder: (context, index) {
|
||||
return MovieItem(movies[index]);
|
||||
},
|
||||
initialScrollIndex: firstMovieTodayOrAfterIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GroupedList<GroupType> extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final int initialScrollIndex;
|
||||
final Widget Function(BuildContext, int) itemBuilder;
|
||||
final Widget Function(GroupType) groupSeparatorBuilder;
|
||||
final GroupType Function(int) groupBy;
|
||||
|
||||
const GroupedList(
|
||||
{required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
required this.groupSeparatorBuilder,
|
||||
required this.groupBy,
|
||||
this.initialScrollIndex = 0,
|
||||
super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (itemCount == 0) {
|
||||
return Container();
|
||||
}
|
||||
List<({int index, GroupType group})> newGroupStarts = [
|
||||
(index: 0, group: groupBy(0))
|
||||
];
|
||||
int internalInitialScrollIndex =
|
||||
initialScrollIndex + (initialScrollIndex > 0 ? 1 : 0);
|
||||
GroupType last = newGroupStarts[0].group;
|
||||
for (int i = 1; i < itemCount; i++) {
|
||||
final GroupType current = groupBy(i);
|
||||
if (current != last) {
|
||||
newGroupStarts.add((index: i, group: current));
|
||||
if (initialScrollIndex > i) {
|
||||
internalInitialScrollIndex++;
|
||||
}
|
||||
}
|
||||
last = current;
|
||||
}
|
||||
|
||||
Widget itemAndSeparatorBuilder(BuildContext context, int index) {
|
||||
int itemIndex = index;
|
||||
for (int i = 0; i < newGroupStarts.length; i++) {
|
||||
if (newGroupStarts[i].index > itemIndex) {
|
||||
break;
|
||||
} else if (newGroupStarts[i].index == itemIndex) {
|
||||
return groupSeparatorBuilder(groupBy(itemIndex));
|
||||
}
|
||||
itemIndex--;
|
||||
}
|
||||
return itemBuilder(context, itemIndex);
|
||||
}
|
||||
|
||||
return ScrollablePositionedList.builder(
|
||||
itemCount: itemCount + newGroupStarts.length,
|
||||
itemBuilder: itemAndSeparatorBuilder,
|
||||
initialScrollIndex: internalInitialScrollIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/model/movie_manager.dart';
|
||||
import 'package:release_schedule/view/movie_list.dart';
|
||||
|
||||
class MovieManagerList extends StatelessWidget {
|
||||
final MovieManager manager;
|
||||
final bool Function(MovieData)? filter;
|
||||
const MovieManagerList(this.manager, {this.filter, super.key});
|
||||
const MovieManagerList(this.manager, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -16,7 +14,7 @@ class MovieManagerList extends StatelessWidget {
|
|||
return Column(
|
||||
children: [
|
||||
manager.loading ? const LinearProgressIndicator() : Container(),
|
||||
Expanded(child: MovieList(manager.movies, filter: filter))
|
||||
Expanded(child: MovieList(manager.movies))
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
class Heading extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const Heading(this.text, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 10),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MoviePage extends StatelessWidget {
|
||||
final MovieData movie;
|
||||
|
||||
const MoviePage(this.movie, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: movie,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: movie.genres
|
||||
?.map((genre) => Chip(label: Text(genre)))
|
||||
.toList() ??
|
||||
[],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Heading("Titles"),
|
||||
Table(
|
||||
border: TableBorder.symmetric(
|
||||
inside: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
children: movie.titles?.map((title) {
|
||||
return TableRow(
|
||||
children: [
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(title.language),
|
||||
)),
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(title.title),
|
||||
))
|
||||
],
|
||||
);
|
||||
}).toList() ??
|
||||
[],
|
||||
),
|
||||
const Heading("Release Dates"),
|
||||
Table(
|
||||
border: TableBorder.symmetric(
|
||||
inside: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
children: movie.releaseDates?.map((releaseDate) {
|
||||
return TableRow(
|
||||
children: [
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(releaseDate.country),
|
||||
)),
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
releaseDate.dateWithPrecision.toString(),
|
||||
),
|
||||
))
|
||||
],
|
||||
);
|
||||
}).toList() ??
|
||||
[],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -219,14 +219,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
scrollable_positioned_list:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
|
@ -14,7 +14,6 @@ dependencies:
|
|||
http: ^1.1.0
|
||||
intl: ^0.18.1
|
||||
get_storage: ^2.1.1
|
||||
scrollable_positioned_list: ^0.3.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 83 KiB |
Binary file not shown.
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
Before Width: | Height: | Size: 103 KiB |
Binary file not shown.
Before Width: | Height: | Size: 78 KiB |
|
@ -0,0 +1,84 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/model/date_format.dart';
|
||||
|
||||
void main() {
|
||||
group('dateRelativeToNow', () {
|
||||
test('returns "Today" for today\'s date', () {
|
||||
final today = DateTime.now();
|
||||
final result = dateRelativeToNow(today);
|
||||
expect(result, 'Today');
|
||||
});
|
||||
|
||||
test('returns "Tomorrow" for tomorrow\'s date', () {
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
final result = dateRelativeToNow(tomorrow);
|
||||
expect(result, 'Tomorrow');
|
||||
});
|
||||
|
||||
test('returns "Yesterday" for yesterday\'s date', () {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final result = dateRelativeToNow(yesterday);
|
||||
expect(result, 'Yesterday');
|
||||
});
|
||||
|
||||
test('returns "In 5 days" for a date 5 days in the future', () {
|
||||
final futureDate = DateTime.now().add(const Duration(days: 5));
|
||||
final result = dateRelativeToNow(futureDate);
|
||||
expect(result, 'In 5 days');
|
||||
});
|
||||
|
||||
test('returns "5 days ago" for a date 5 days in the past', () {
|
||||
final pastDate = DateTime.now().subtract(const Duration(days: 5));
|
||||
final result = dateRelativeToNow(pastDate);
|
||||
expect(result, '5 days ago');
|
||||
});
|
||||
|
||||
test('returns "a week" for a date 7 days in the future', () {
|
||||
final futureDate = DateTime.now().add(const Duration(days: 7));
|
||||
final result = dateRelativeToNow(futureDate);
|
||||
expect(result, 'In a week');
|
||||
});
|
||||
|
||||
test('returns "a week" for a date 7 days in the past', () {
|
||||
final pastDate = DateTime.now().subtract(const Duration(days: 7));
|
||||
final result = dateRelativeToNow(pastDate);
|
||||
expect(result, 'A week ago');
|
||||
});
|
||||
|
||||
test('returns "a month" for a date 30 days in the future', () {
|
||||
final futureDate = DateTime.now().add(const Duration(days: 30));
|
||||
final result = dateRelativeToNow(futureDate);
|
||||
expect(result, 'In a month');
|
||||
});
|
||||
|
||||
test('returns "a month" for a date 30 days in the past', () {
|
||||
final pastDate = DateTime.now().subtract(const Duration(days: 30));
|
||||
final result = dateRelativeToNow(pastDate);
|
||||
expect(result, 'A month ago');
|
||||
});
|
||||
|
||||
test('returns "a year" for a date 365 days in the future', () {
|
||||
final futureDate = DateTime.now().add(const Duration(days: 365));
|
||||
final result = dateRelativeToNow(futureDate);
|
||||
expect(result, 'In a year');
|
||||
});
|
||||
|
||||
test('returns "a year" for a date 365 days in the past', () {
|
||||
final pastDate = DateTime.now().subtract(const Duration(days: 365));
|
||||
final result = dateRelativeToNow(pastDate);
|
||||
expect(result, 'A year ago');
|
||||
});
|
||||
|
||||
test('returns "a century" for a date 100 years in the future', () {
|
||||
final futureDate = DateTime.now().add(const Duration(days: 365 * 100));
|
||||
final result = dateRelativeToNow(futureDate);
|
||||
expect(result, 'In a century');
|
||||
});
|
||||
|
||||
test('returns "a century" for a date 100 years in the past', () {
|
||||
final pastDate = DateTime.now().subtract(const Duration(days: 365 * 100));
|
||||
final result = dateRelativeToNow(pastDate);
|
||||
expect(result, 'A century ago');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
|
||||
void main() {
|
||||
group("DatePrecisionComparison", () {
|
||||
test("can compare with inequality", () {
|
||||
expect(DatePrecision.decade < DatePrecision.year, isTrue);
|
||||
expect(DatePrecision.year <= DatePrecision.year, isTrue);
|
||||
expect(DatePrecision.month > DatePrecision.day, isFalse);
|
||||
expect(DatePrecision.day > DatePrecision.day, isFalse);
|
||||
expect(DatePrecision.hour >= DatePrecision.month, isTrue);
|
||||
});
|
||||
|
||||
test("can compare with equality", () {
|
||||
expect(DatePrecision.decade == DatePrecision.decade, isTrue);
|
||||
expect(DatePrecision.year != DatePrecision.decade, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test("simplifyDatesToPrecision", () {
|
||||
expect(simplifyDateToPrecision(DateTime(2024, 5, 14), DatePrecision.decade),
|
||||
equals(DateTime(2020, 1, 1)));
|
||||
expect(simplifyDateToPrecision(DateTime(2024, 5, 14), DatePrecision.year),
|
||||
equals(DateTime(2024, 1, 1)));
|
||||
expect(simplifyDateToPrecision(DateTime(2024, 5, 14), DatePrecision.month),
|
||||
equals(DateTime(2024, 5, 1)));
|
||||
expect(
|
||||
simplifyDateToPrecision(
|
||||
DateTime(2024, 5, 14, 10, 42), DatePrecision.day),
|
||||
equals(DateTime(2024, 5, 14)));
|
||||
expect(
|
||||
simplifyDateToPrecision(
|
||||
DateTime(2024, 5, 14, 10, 42), DatePrecision.hour),
|
||||
equals(DateTime(2024, 5, 14, 10)));
|
||||
expect(
|
||||
simplifyDateToPrecision(
|
||||
DateTime(2024, 5, 14, 10, 42, 12), DatePrecision.minute),
|
||||
equals(DateTime(2024, 5, 14, 10, 42)));
|
||||
});
|
||||
|
||||
group("DateWithPrecision", () {
|
||||
test("includes", () {
|
||||
DateTime originalDate = DateTime(2024, 5, 14, 15, 42, 12);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.minute)
|
||||
.includes(DateTime(2024, 5, 14, 15, 42, 12)),
|
||||
isTrue);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.minute)
|
||||
.includes(DateTime(2024, 5, 14, 15, 43, 1)),
|
||||
isFalse);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.hour)
|
||||
.includes(DateTime(2024, 5, 14, 15, 42, 12)),
|
||||
isTrue);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.hour)
|
||||
.includes(DateTime(2024, 5, 14, 16, 10, 12)),
|
||||
isFalse);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.day)
|
||||
.includes(DateTime(2024, 5, 14)),
|
||||
isTrue);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.day)
|
||||
.includes(DateTime(2024, 5, 15)),
|
||||
isFalse);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.month)
|
||||
.includes(DateTime(2024, 5, 20)),
|
||||
isTrue);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.month)
|
||||
.includes(DateTime(2024, 6, 10)),
|
||||
isFalse);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.year)
|
||||
.includes(DateTime(2024, 12, 31)),
|
||||
isTrue);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.year)
|
||||
.includes(DateTime(2025, 1, 1)),
|
||||
isFalse);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.decade)
|
||||
.includes(DateTime(2029, 12, 31)),
|
||||
isTrue);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.decade)
|
||||
.includes(DateTime(2020, 1, 1)),
|
||||
isTrue);
|
||||
expect(
|
||||
DateWithPrecision(originalDate, DatePrecision.decade)
|
||||
.includes(DateTime(2030, 1, 1)),
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test("toString", () {
|
||||
DateTime date = DateTime(2024, 5, 14, 15, 42, 12);
|
||||
expect(DateWithPrecision(date, DatePrecision.minute).toString(),
|
||||
equals("May 14, 2024, 15:42"));
|
||||
expect(DateWithPrecision(date, DatePrecision.hour).toString(),
|
||||
equals("May 14, 2024, 15"));
|
||||
expect(DateWithPrecision(date, DatePrecision.day).toString(),
|
||||
equals("May 14, 2024"));
|
||||
expect(DateWithPrecision(date, DatePrecision.month).toString(),
|
||||
equals("May 2024"));
|
||||
expect(DateWithPrecision(date, DatePrecision.year).toString(),
|
||||
equals("2024"));
|
||||
expect(DateWithPrecision(date, DatePrecision.decade).toString(),
|
||||
equals("2020s"));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/api/json_helper.dart';
|
||||
|
||||
void main() {
|
||||
group("selectInJson", () {
|
||||
late Map<String, dynamic> json;
|
||||
|
||||
setUp(() {
|
||||
json = {
|
||||
"a": {
|
||||
"b": [
|
||||
{"c": 1},
|
||||
{"c": 2},
|
||||
{"c": 3},
|
||||
],
|
||||
"c": 4,
|
||||
},
|
||||
"d": [
|
||||
{"e": 5},
|
||||
{"e": 6},
|
||||
{"e": "7"},
|
||||
{"e": 7},
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
test("should select a value", () {
|
||||
expect(selectInJson<int>(json, "a.b.1.c").toList(), equals([2]));
|
||||
});
|
||||
|
||||
test("should select multiple values", () {
|
||||
expect(selectInJson<int>(json, "a.b.*.c").toList(), equals([1, 2, 3]));
|
||||
});
|
||||
|
||||
test("should select multiple values with nested lists", () {
|
||||
expect(selectInJson<int>(json, "a.**.c").toList(), equals([4, 1, 2, 3]));
|
||||
});
|
||||
|
||||
test("should select multiple values with nested lists and maps", () {
|
||||
expect(selectInJson<int>(json, "**.e").toList(), equals([5, 6, 7]));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/local_movie_storage.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/model/movie_manager.dart';
|
||||
|
@ -12,7 +11,7 @@ void main() {
|
|||
setUp(() {
|
||||
movieManager = MovieManager(
|
||||
MovieApi(),
|
||||
InMemoryMovieStorage(),
|
||||
LocalMovieStorage(),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -64,38 +63,7 @@ void main() {
|
|||
expect(movieManager.movies, equals([...movies, ...newMovies]));
|
||||
});
|
||||
|
||||
test('addMovies should update existing movies', () {
|
||||
final movies = [
|
||||
MovieData(
|
||||
'The Matrix',
|
||||
DateWithPrecisionAndCountry(DateTime(1999, 3, 31), DatePrecision.day,
|
||||
'United States of America'),
|
||||
),
|
||||
MovieData(
|
||||
'The Matrix Reloaded',
|
||||
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
|
||||
'United States of America'),
|
||||
),
|
||||
];
|
||||
|
||||
movieManager.addMovies(movies);
|
||||
|
||||
final updatedMovie = MovieData(
|
||||
'The Matrix Reloaded',
|
||||
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
|
||||
'United States of America'),
|
||||
)..setDetails(
|
||||
bookmarked: true,
|
||||
genres: ['Action', 'Adventure'],
|
||||
);
|
||||
|
||||
movieManager.addMovies([updatedMovie]);
|
||||
|
||||
expect(movieManager.movies[1].genres, equals(updatedMovie.genres));
|
||||
expect(movieManager.movies[1].bookmarked, equals(false));
|
||||
});
|
||||
|
||||
test('addMovies should sort movies by their release dates', () {
|
||||
test("addMovies should sort movies by their release dates", () {
|
||||
final movies = [
|
||||
MovieData(
|
||||
'The Matrix Reloaded',
|
||||
|
@ -114,27 +82,6 @@ void main() {
|
|||
expect(movieManager.movies, equals([...movies.reversed]));
|
||||
});
|
||||
|
||||
test(
|
||||
'addMovies should sort movies that have a less precise release date before movies with more precise release dates',
|
||||
() {
|
||||
final movies = [
|
||||
MovieData(
|
||||
'The Matrix Reloaded',
|
||||
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
|
||||
'United States of America'),
|
||||
),
|
||||
MovieData(
|
||||
'The Matrix',
|
||||
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.month,
|
||||
'United States of America'),
|
||||
),
|
||||
];
|
||||
|
||||
movieManager.addMovies(movies);
|
||||
|
||||
expect(movieManager.movies, equals([...movies.reversed]));
|
||||
});
|
||||
|
||||
test(
|
||||
'when a movie is modified and it\'s date is changed the movies should be resorted',
|
||||
() async {
|
||||
|
@ -188,28 +135,5 @@ void main() {
|
|||
|
||||
expect(movieManager.movies, equals([notRemoved]));
|
||||
});
|
||||
|
||||
test("localSearch", () {
|
||||
final movies = [
|
||||
MovieData(
|
||||
'The Matrix',
|
||||
DateWithPrecisionAndCountry(DateTime(1999, 3, 31), DatePrecision.day,
|
||||
'United States of America'),
|
||||
),
|
||||
MovieData(
|
||||
'The Matrix Reloaded',
|
||||
DateWithPrecisionAndCountry(DateTime(2003, 5, 7), DatePrecision.day,
|
||||
'United States of America'),
|
||||
),
|
||||
];
|
||||
|
||||
movieManager.addMovies(movies);
|
||||
|
||||
expect(movieManager.localSearch('Matrix'), equals(movies));
|
||||
expect(movieManager.localSearch('Matrix Re'),
|
||||
equals(movies.reversed.toList()));
|
||||
expect(movieManager.localSearch('Matrix Reloaded'), equals([movies[1]]));
|
||||
expect(movieManager.localSearch('Matrix Revolutions'), equals([]));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
void main() {
|
||||
|
@ -21,8 +20,10 @@ void main() {
|
|||
'Adventure'
|
||||
], titles: [
|
||||
(title: 'Title 2', language: 'en')
|
||||
], reviews: [
|
||||
Review('8.5', 'John Doe', DateTime(2023, 1, 1), 100)
|
||||
]);
|
||||
movie1.updateWithNewIgnoringUserControlled(movie2);
|
||||
movie1.updateWithNew(movie2);
|
||||
expect(movie1.title, equals('Title 2'));
|
||||
expect(movie1.releaseDate.country, equals('UK'));
|
||||
expect(movie1.releaseDates!.length, equals(1));
|
||||
|
@ -33,6 +34,10 @@ void main() {
|
|||
expect(movie1.titles!.length, equals(1));
|
||||
expect(movie1.titles![0].title, equals('Title 2'));
|
||||
expect(movie1.titles![0].language, equals('en'));
|
||||
expect(movie1.reviews!.length, equals(1));
|
||||
expect(movie1.reviews![0].score, equals('8.5'));
|
||||
expect(movie1.reviews![0].by, equals('John Doe'));
|
||||
expect(movie1.reviews![0].count, equals(100));
|
||||
});
|
||||
|
||||
test('same() returns true for same title and release date', () {
|
||||
|
@ -83,6 +88,8 @@ void main() {
|
|||
'Adventure'
|
||||
], titles: [
|
||||
(title: 'Title 2', language: 'en')
|
||||
], reviews: [
|
||||
Review('8.5', 'John Doe', DateTime(2023, 1, 1), 100)
|
||||
]);
|
||||
final json = movie.toJsonEncodable();
|
||||
final movie2 = MovieData.fromJsonEncodable(json);
|
||||
|
@ -96,6 +103,10 @@ void main() {
|
|||
expect(movie2.titles!.length, equals(1));
|
||||
expect(movie2.titles![0].title, equals('Title 2'));
|
||||
expect(movie2.titles![0].language, equals('en'));
|
||||
expect(movie2.reviews!.length, equals(1));
|
||||
expect(movie2.reviews![0].score, equals('8.5'));
|
||||
expect(movie2.reviews![0].by, equals('John Doe'));
|
||||
expect(movie2.reviews![0].count, equals(100));
|
||||
});
|
||||
|
||||
test('toString()', () {
|
||||
|
@ -111,6 +122,8 @@ void main() {
|
|||
'Adventure'
|
||||
], titles: [
|
||||
(title: 'Title 2', language: 'en')
|
||||
], reviews: [
|
||||
Review('8.5', 'John Doe', DateTime(2023, 1, 1), 100)
|
||||
]);
|
||||
expect(movie.toString(),
|
||||
equals('Title 1 (January 1, 2023 (US); Action, Adventure)'));
|
||||
|
@ -123,9 +136,8 @@ void main() {
|
|||
DateTime(2023, 1, 1), DatePrecision.day, 'US');
|
||||
final json = date.toJsonEncodable();
|
||||
final date2 = DateWithPrecisionAndCountry.fromJsonEncodable(json);
|
||||
expect(date2.dateWithPrecision, equals(date.dateWithPrecision));
|
||||
expect(date2.dateWithPrecision.precision,
|
||||
equals(date.dateWithPrecision.precision));
|
||||
expect(date2.date, equals(date.date));
|
||||
expect(date2.precision, equals(date.precision));
|
||||
expect(date2.country, equals(date.country));
|
||||
});
|
||||
|
||||
|
|
|
@ -2,62 +2,24 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/main.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/local_movie_storage.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/model/movie_manager.dart';
|
||||
import 'package:release_schedule/view/movie_manager_list.dart';
|
||||
|
||||
void main() {
|
||||
group('HomePage', () {
|
||||
late LocalMovieStorage storage;
|
||||
|
||||
setUp(() {
|
||||
storage = InMemoryMovieStorage();
|
||||
storage.update([
|
||||
MovieData(
|
||||
'The Shawshank Redemption',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
|
||||
),
|
||||
MovieData(
|
||||
'The Godfather',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1972, 3, 24), DatePrecision.day, 'US'),
|
||||
),
|
||||
MovieData(
|
||||
'The Dark Knight',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(2008, 7, 18), DatePrecision.day, 'US'),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('displays search bar', (WidgetTester tester) async {
|
||||
MovieManager movieManager = MovieManager(MovieApi(), storage);
|
||||
testWidgets('displays title', (WidgetTester tester) async {
|
||||
MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage());
|
||||
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(find.text('Search'), findsOneWidget);
|
||||
|
||||
expect(find.text('Release Schedule'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays list of releases', (WidgetTester tester) async {
|
||||
MovieManager movieManager = MovieManager(MovieApi(), storage);
|
||||
MovieManager movieManager = MovieManager(MovieApi(), LocalMovieStorage());
|
||||
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(find.byType(MovieManagerList), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays search results', (WidgetTester tester) async {
|
||||
MovieManager movieManager = MovieManager(MovieApi(), storage);
|
||||
await tester.pumpWidget(MaterialApp(home: HomePage(movieManager)));
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField), 'The Shawshank Redemption');
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('The Shawshank Redemption'), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/view/movie_item.dart';
|
||||
import 'package:release_schedule/view/movie_page.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('MovieItem displays movie data', (WidgetTester tester) async {
|
||||
|
@ -26,6 +24,9 @@ void main() {
|
|||
|
||||
expect(find.text('Test Movie'), findsOneWidget);
|
||||
|
||||
final formattedDate = movie.releaseDate.toString();
|
||||
expect(find.textContaining(formattedDate), findsOneWidget);
|
||||
|
||||
expect(find.textContaining('Action, Adventure'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
@ -57,88 +58,4 @@ void main() {
|
|||
|
||||
expect(find.textContaining('Action, Adventure, Comedy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should update when the movie is bookmarked', (tester) async {
|
||||
final movie = MovieData(
|
||||
'Test Movie',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(2023, 1, 1), DatePrecision.day, 'US'),
|
||||
);
|
||||
movie.setDetails(
|
||||
genres: ['Action', 'Adventure'],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MovieItem(movie),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.bookmark_border), findsOneWidget);
|
||||
|
||||
movie.setDetails(
|
||||
bookmarked: true,
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.bookmark_added), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("should update the bookmark state when the icon is tapped",
|
||||
(tester) async {
|
||||
final movie = MovieData(
|
||||
'Test Movie',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(2023, 1, 1), DatePrecision.day, 'US'),
|
||||
);
|
||||
movie.setDetails(
|
||||
genres: ['Action', 'Adventure'],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MovieItem(movie),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.bookmark_border), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bookmark_outline));
|
||||
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.bookmark_added), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("should navigate to MoviePage when tapped", (tester) async {
|
||||
final movie = MovieData(
|
||||
'Test Movie',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(2023, 1, 1), DatePrecision.day, 'US'),
|
||||
);
|
||||
movie.setDetails(
|
||||
genres: ['Action', 'Adventure'],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MovieItem(movie),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.bookmark_border), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(ListTile));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MoviePage), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/view/movie_item.dart';
|
||||
import 'package:release_schedule/view/movie_list.dart';
|
||||
|
@ -29,40 +28,7 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MovieItem), findsNWidgets(movies.length));
|
||||
});
|
||||
|
||||
testWidgets("should filter the list of movies",
|
||||
(WidgetTester tester) async {
|
||||
final movies = [
|
||||
MovieData(
|
||||
'The Shawshank Redemption',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
|
||||
),
|
||||
MovieData(
|
||||
'The Godfather',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1972, 3, 24), DatePrecision.day, 'US'),
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MovieList(
|
||||
movies,
|
||||
filter: (movie) => movie.title.contains('Godfather'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MovieItem), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/api/movie_api.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/local_movie_storage.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/model/movie_manager.dart';
|
||||
|
@ -12,7 +11,7 @@ import 'package:release_schedule/view/movie_manager_list.dart';
|
|||
void main() {
|
||||
group('MovieManagerList', () {
|
||||
testWidgets('displays movie list', (tester) async {
|
||||
final manager = MovieManager(MovieApi(), InMemoryMovieStorage());
|
||||
final manager = MovieManager(MovieApi(), LocalMovieStorage());
|
||||
manager.addMovies([
|
||||
MovieData(
|
||||
'Movie 1',
|
||||
|
@ -34,7 +33,7 @@ void main() {
|
|||
});
|
||||
|
||||
testWidgets('updates when new movies are added', (tester) async {
|
||||
final manager = MovieManager(MovieApi(), InMemoryMovieStorage());
|
||||
final manager = MovieManager(MovieApi(), LocalMovieStorage());
|
||||
manager.addMovies([
|
||||
MovieData(
|
||||
'Movie 1',
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:release_schedule/model/dates.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
import 'package:release_schedule/view/movie_page.dart';
|
||||
|
||||
void main() {
|
||||
group('MoviePage', () {
|
||||
testWidgets('should render the movie details', (WidgetTester tester) async {
|
||||
final movie = MovieData(
|
||||
'The Shawshank Redemption',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MoviePage(movie),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(movie.title), findsAtLeastNWidgets(1));
|
||||
});
|
||||
|
||||
testWidgets('should bookmark the movie', (WidgetTester tester) async {
|
||||
final movie = MovieData(
|
||||
'The Shawshank Redemption',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MoviePage(movie),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(movie.bookmarked, isFalse);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.bookmark_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(movie.bookmarked, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("should display the movie's genres", (WidgetTester tester) async {
|
||||
final movie = MovieData(
|
||||
'The Shawshank Redemption',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
|
||||
)..setDetails(genres: ['Drama']);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MoviePage(movie),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Drama'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("should display the movie's titles and release dates",
|
||||
(WidgetTester tester) async {
|
||||
final movie = MovieData(
|
||||
'The Shawshank Redemption',
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1994, 9, 22), DatePrecision.day, 'US'),
|
||||
)..setDetails(
|
||||
titles: [(title: 'The Shawshank Redemption', language: 'en')],
|
||||
releaseDates: [
|
||||
DateWithPrecisionAndCountry(
|
||||
DateTime(1994, 9, 22), DatePrecision.day, 'US')
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MoviePage(movie),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('en'), findsOneWidget);
|
||||
expect(find.text('The Shawshank Redemption'), findsNWidgets(2));
|
||||
|
||||
expect(find.text('US'), findsOneWidget);
|
||||
expect(find.textContaining('1994'), findsOneWidget);
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue