Add local caching of movies
parent
91e18b279d
commit
a977ad1f34
|
@ -1,7 +1,7 @@
|
|||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
abstract class MovieApi<CustomMovieData extends MovieData> {
|
||||
Future<List<CustomMovieData>> getUpcomingMovies([int count]);
|
||||
Future<List<CustomMovieData>> searchForMovies(String searchTerm);
|
||||
Future<void> addMovieDetails(List<CustomMovieData> movies);
|
||||
abstract class MovieApi {
|
||||
Future<List<MovieData>> getUpcomingMovies([int count]);
|
||||
Future<List<MovieData>> searchForMovies(String searchTerm);
|
||||
Future<void> addMovieDetails(List<MovieData> movies);
|
||||
}
|
||||
|
|
|
@ -10,10 +10,19 @@ class WikidataMovieData extends MovieData {
|
|||
WikidataMovieData(String title, DateTime releaseDate, this.entityId)
|
||||
: super(title, releaseDate);
|
||||
|
||||
WikidataMovieData.fromEncodable(Map encodable)
|
||||
: entityId = encodable["entityId"],
|
||||
super.fromJsonEncodable(encodable);
|
||||
|
||||
@override
|
||||
bool same(MovieData other) {
|
||||
return other is WikidataMovieData && entityId == other.entityId;
|
||||
}
|
||||
|
||||
@override
|
||||
Map toJsonEncodable() {
|
||||
return super.toJsonEncodable()..addAll({"entityId": entityId});
|
||||
}
|
||||
}
|
||||
|
||||
String createUpcomingMovieQuery(int limit) {
|
||||
|
@ -35,13 +44,13 @@ ORDER BY ?minReleaseDate
|
|||
LIMIT $limit""";
|
||||
}
|
||||
|
||||
class WikidataMovieApi implements MovieApi<WikidataMovieData> {
|
||||
class WikidataMovieApi implements MovieApi {
|
||||
ApiManager searchApi = ApiManager("https://www.wikidata.org/w/api.php");
|
||||
ApiManager queryApi =
|
||||
ApiManager("https://query.wikidata.org/sparql?format=json");
|
||||
|
||||
@override
|
||||
Future<void> addMovieDetails(List<WikidataMovieData> movies) {
|
||||
Future<void> addMovieDetails(List<MovieData> movies) {
|
||||
// TODO: implement addMovieDetails
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
|
|
@ -2,11 +2,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/movie_manager.dart';
|
||||
import 'package:release_schedule/view/movie_list.dart';
|
||||
import 'package:release_schedule/view/movie_manager_list.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
movieManager.loadUpcomingMovies();
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
|
@ -22,33 +21,25 @@ class MyApp extends StatelessWidget {
|
|||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: HomePage(),
|
||||
home: HomePage(movieManager),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
final MovieApi api = WikidataMovieApi();
|
||||
final MovieManager manager;
|
||||
|
||||
HomePage({super.key});
|
||||
HomePage(this.manager, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Release Schedule")),
|
||||
body: AnimatedBuilder(
|
||||
animation: movieManager,
|
||||
// future: api.getUpcomingMovies(),
|
||||
builder: (context, widget) {
|
||||
return MovieList(movieManager.movies);
|
||||
// var data = snapshot.data;
|
||||
// if (snapshot.hasData && data != null) {
|
||||
// return MovieList(data);
|
||||
// } else if (snapshot.hasError) {
|
||||
// return ErrorWidget(snapshot.error ?? "Something went wrong");
|
||||
// }
|
||||
// return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
body: MovieManagerList(manager),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.refresh),
|
||||
onPressed: () => manager.loadUpcomingMovies(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
class LocalMovieStorage {
|
||||
List<MovieData> _storedMovies = [];
|
||||
update(List<MovieData> movies) {
|
||||
_storedMovies = movies;
|
||||
}
|
||||
|
||||
Future<List<MovieData>> retrieve() async {
|
||||
return _storedMovies;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalMovieStorageGetStorage extends LocalMovieStorage {
|
||||
Future<void>? initialized;
|
||||
GetStorage? container;
|
||||
MovieData Function(Map jsonEncodable) toMovieData;
|
||||
|
||||
LocalMovieStorageGetStorage(this.toMovieData) {
|
||||
initialized = _init();
|
||||
}
|
||||
_init() async {
|
||||
await GetStorage.init("movies");
|
||||
container = GetStorage("movies");
|
||||
}
|
||||
|
||||
@override
|
||||
update(List<MovieData> movies) async {
|
||||
await initialized;
|
||||
container!.write(
|
||||
"movies", movies.map((movie) => movie.toJsonEncodable()).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MovieData>> retrieve() async {
|
||||
await initialized;
|
||||
dynamic movies = container!.read("movies");
|
||||
if (movies == null) {
|
||||
return [];
|
||||
}
|
||||
return (movies as List<dynamic>)
|
||||
.map((encodable) => toMovieData(encodable))
|
||||
.toList();
|
||||
}
|
||||
}
|
|
@ -7,19 +7,41 @@ class Review {
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
typedef ReleaseDateInCountry = (String country, DateTime date);
|
||||
typedef TitleInCountry = (String country, String title);
|
||||
|
||||
class MovieData extends ChangeNotifier {
|
||||
final String title;
|
||||
final DateTime releaseDate;
|
||||
String _title;
|
||||
DateTime _releaseDate;
|
||||
bool _hasDetails = false;
|
||||
List<ReleaseDateInCountry> _releaseDates = [];
|
||||
List<String> _genres = [];
|
||||
List<TitleInCountry> _titles = [];
|
||||
List<Review> _reviews = [];
|
||||
List<ReleaseDateInCountry>? _releaseDates;
|
||||
List<String>? _genres;
|
||||
List<TitleInCountry>? _titles;
|
||||
List<Review>? _reviews;
|
||||
|
||||
String get title {
|
||||
return _title;
|
||||
}
|
||||
|
||||
DateTime get releaseDate {
|
||||
return _releaseDate;
|
||||
}
|
||||
|
||||
List<ReleaseDateInCountry>? get releaseDates {
|
||||
return _releaseDates;
|
||||
|
@ -41,6 +63,14 @@ class MovieData extends ChangeNotifier {
|
|||
return _hasDetails;
|
||||
}
|
||||
|
||||
void updateWithNew(MovieData movie) {
|
||||
setDetails(
|
||||
releaseDates: movie.releaseDates,
|
||||
genres: movie.genres,
|
||||
titles: movie.titles,
|
||||
reviews: movie.reviews);
|
||||
}
|
||||
|
||||
void setDetails(
|
||||
{List<ReleaseDateInCountry>? releaseDates,
|
||||
List<String>? genres,
|
||||
|
@ -64,12 +94,49 @@ class MovieData extends ChangeNotifier {
|
|||
|
||||
@override
|
||||
String toString() {
|
||||
return "$title (${releaseDate.year}${_genres.isNotEmpty ? "; ${_genres.join(", ")}" : ""})";
|
||||
return "$title (${releaseDate.year}${_genres?.isNotEmpty ?? true ? "; ${_genres?.join(", ")}" : ""})";
|
||||
}
|
||||
|
||||
Map toJsonEncodable() {
|
||||
List? releaseDatesByCountry =
|
||||
_releaseDates?.map((e) => [e.$1, e.$2.toIso8601String()]).toList();
|
||||
List? titlesByCountry = _titles?.map((e) => [e.$1, e.$2]).toList();
|
||||
return {
|
||||
"title": title,
|
||||
"releaseDate": releaseDate.toIso8601String(),
|
||||
"releaseDates": releaseDatesByCountry,
|
||||
"genres": genres,
|
||||
"titles": titlesByCountry,
|
||||
"reviews": reviews,
|
||||
};
|
||||
}
|
||||
|
||||
bool same(MovieData other) {
|
||||
return title == other.title && releaseDate == other.releaseDate;
|
||||
}
|
||||
|
||||
MovieData(this.title, this.releaseDate);
|
||||
MovieData(this._title, this._releaseDate);
|
||||
|
||||
MovieData.fromJsonEncodable(Map json)
|
||||
: _title = json["title"],
|
||||
_releaseDate = DateTime.parse(json["releaseDate"]) {
|
||||
setDetails(
|
||||
genres: json["genres"],
|
||||
releaseDates: json["releaseDates"] != null
|
||||
? (json["releaseDates"] as List<List<dynamic>>)
|
||||
.map((release) => ((release[0], DateTime.parse(release[1]))
|
||||
as ReleaseDateInCountry))
|
||||
.toList()
|
||||
: null,
|
||||
reviews: json["reviews"] != null
|
||||
? (json["reviews"] as List<Map<String, dynamic>>)
|
||||
.map((review) => Review.fromJsonEncodable(review))
|
||||
.toList()
|
||||
: null,
|
||||
titles: json["titles"] != null
|
||||
? (json["titles"] as List<dynamic>)
|
||||
.map((title) => (title[0], title[1]) as TitleInCountry)
|
||||
.toList()
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
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/local_movie_storage.dart';
|
||||
import 'package:release_schedule/model/movie.dart';
|
||||
|
||||
T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
|
||||
|
@ -11,22 +14,69 @@ T? firstWhereOrNull<T>(List<T> list, bool Function(T element) test) {
|
|||
}
|
||||
}
|
||||
|
||||
final movieManager = MovieManager(WikidataMovieApi());
|
||||
class DelayedFunctionCaller {
|
||||
final Function function;
|
||||
final Duration duration;
|
||||
Timer? _timer;
|
||||
|
||||
class MovieManager<CustomMovieData extends MovieData> extends ChangeNotifier {
|
||||
final List<CustomMovieData> movies = List.empty(growable: true);
|
||||
final MovieApi<CustomMovieData> api;
|
||||
DelayedFunctionCaller(this.function, this.duration);
|
||||
|
||||
MovieManager(this.api);
|
||||
void call() {
|
||||
// If a timer is already active, return.
|
||||
if (_timer != null && _timer!.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<CustomMovieData> addMovies(List<CustomMovieData> additionalMovies) {
|
||||
List<CustomMovieData> actualMovies = [];
|
||||
// Create a timer that calls the function after the specified duration.
|
||||
_timer = Timer(duration, () {
|
||||
function();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
DelayedFunctionCaller? cacheUpdater;
|
||||
bool cacheLoaded = false;
|
||||
|
||||
MovieManager(this.api, this.cache) {
|
||||
cacheUpdater = DelayedFunctionCaller(() {
|
||||
cache.update(movies);
|
||||
}, const Duration(seconds: 3));
|
||||
|
||||
_loadCache();
|
||||
}
|
||||
|
||||
_loadCache() async {
|
||||
addMovies(await cache.retrieve());
|
||||
}
|
||||
|
||||
_moviesModified({bool withoutAddingOrRemoving = false}) {
|
||||
cacheUpdater?.call();
|
||||
if (!withoutAddingOrRemoving) {
|
||||
// only notify listeners if movies are added or removed
|
||||
// if they are modified in place they will notify listeners themselves
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
List<MovieData> addMovies(List<MovieData> additionalMovies) {
|
||||
List<MovieData> actualMovies = [];
|
||||
bool added = false;
|
||||
for (var movie in additionalMovies) {
|
||||
CustomMovieData? existing =
|
||||
MovieData? existing =
|
||||
firstWhereOrNull(movies, (element) => movie.same(element));
|
||||
if (existing == null) {
|
||||
movies.add(movie);
|
||||
movie.addListener(() {
|
||||
_moviesModified(withoutAddingOrRemoving: true);
|
||||
});
|
||||
added = true;
|
||||
actualMovies.add(movie);
|
||||
} else {
|
||||
|
@ -34,27 +84,48 @@ class MovieManager<CustomMovieData extends MovieData> extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
if (added) {
|
||||
notifyListeners();
|
||||
_moviesModified();
|
||||
}
|
||||
return actualMovies;
|
||||
}
|
||||
|
||||
removeMoviesWhere(bool Function(MovieData movie) test) {
|
||||
bool removedMovies = false;
|
||||
for (int i = movies.length - 1; i >= 0; i--) {
|
||||
bool remove = test(movies[i]);
|
||||
if (remove) {
|
||||
removedMovies = true;
|
||||
movies.removeAt(i);
|
||||
}
|
||||
}
|
||||
if (removedMovies) {
|
||||
_moviesModified();
|
||||
}
|
||||
}
|
||||
|
||||
/// Only search locally cached movies.
|
||||
localSearch(String search) {}
|
||||
|
||||
/// Online search for movies.
|
||||
Future<List<CustomMovieData>> search(String search) async {
|
||||
List<CustomMovieData> movies = await api.searchForMovies(search);
|
||||
Future<List<MovieData>> search(String search) async {
|
||||
List<MovieData> movies = await api.searchForMovies(search);
|
||||
return addMovies(movies);
|
||||
}
|
||||
|
||||
expandDetails(List<CustomMovieData> movies) {
|
||||
expandDetails(List<MovieData> movies) {
|
||||
api.addMovieDetails(movies);
|
||||
}
|
||||
|
||||
loadUpcomingMovies() async {
|
||||
List<CustomMovieData> movies = await api.getUpcomingMovies();
|
||||
try {
|
||||
loading = true;
|
||||
notifyListeners();
|
||||
List<MovieData> movies = await api.getUpcomingMovies();
|
||||
addMovies(movies);
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -44,6 +44,8 @@ String durationApproximatedInWords(Duration duration) {
|
|||
String durationToRelativeTimeString(Duration duration) {
|
||||
if (duration.isNegative) {
|
||||
return "${durationApproximatedInWords(-duration)} ago";
|
||||
} else if (duration == Duration.zero) {
|
||||
return "now";
|
||||
} else {
|
||||
return "in ${durationApproximatedInWords(duration)}";
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:release_schedule/model/movie_manager.dart';
|
||||
import 'package:release_schedule/view/movie_list.dart';
|
||||
|
||||
class MovieManagerList extends StatelessWidget {
|
||||
final MovieManager manager;
|
||||
const MovieManagerList(this.manager, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: manager,
|
||||
builder: (context, child) {
|
||||
return Column(
|
||||
children: [
|
||||
manager.loading ? const LinearProgressIndicator() : Container(),
|
||||
Expanded(child: MovieList(manager.movies))
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -5,6 +5,8 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import path_provider_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
}
|
||||
|
|
105
pubspec.lock
105
pubspec.lock
|
@ -57,6 +57,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
@ -75,6 +83,22 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
get:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: get
|
||||
sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.6"
|
||||
get_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: get_storage
|
||||
sha256: "39db1fffe779d0c22b3a744376e86febe4ade43bf65e06eab5af707dc84185a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -139,6 +163,70 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.3"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "0a279f0707af40c890e80b1e9df8bb761694c074ba7e1d4ab1bc4b728e200b59"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
@ -216,5 +304,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4-beta"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.9"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
sdks:
|
||||
dart: ">=3.1.3 <4.0.0"
|
||||
flutter: ">=3.13.0"
|
||||
|
|
|
@ -37,6 +37,7 @@ dependencies:
|
|||
cupertino_icons: ^1.0.2
|
||||
http: ^1.1.0
|
||||
intl: ^0.18.1
|
||||
get_storage: ^2.1.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in New Issue