diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..811736d --- /dev/null +++ b/.gitignore @@ -0,0 +1,94 @@ +# Files and directories created by pub +.dart_tool/ +.packages +# Remove the following pattern if you wish to check in your lock file +pubspec.lock + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +## Test config +test-unsplash-credentials.json diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..5c98b42 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,2 @@ +# Default ignored files +/workspace.xml \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..28a804d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..412b793 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/unsplash_client.iml b/.idea/unsplash_client.iml new file mode 100644 index 0000000..ae9af97 --- /dev/null +++ b/.idea/unsplash_client.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..78cface --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..124e6ad --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- TODO diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d7c5a8 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +An unofficial client to for the [unsplash](https://unsplash.com) api. + +This is a work in progress. At the moment only **Photos.list** and **Photos.random** are +implemented. + +## Usage + +A simple usage example: + +```dart +import 'package:unsplash_client/client.dart'; + +void main() async { + // Create a client. + final client = UnsplashClient( + settings: Settings( + // Use the credentials from the developer portal. + credentials: AppCredentials( + accessKey: '...', + secretKey: '...', + ) + ), + ); + + // Fetch 5 random photos. + final response = await client.photos.random(count: 5).go(); + + // Check that the request was successful. + if (!response.isOk) { + throw 'Something is wrong: $response'; + } + + // Do something with the photos. + final photos = response.data; + + // Create a dynamically resized url. + final resizedUrl = photos.first.urls.raw.resize( + width: 400, + height: 400, + fit: ResizeFitMode.cover, + format: ImageFormat.webp, + ); + +} +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..9dbe1cb --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:effective_dart/analysis_options.1.2.0.yaml + +linter: + rules: + - prefer_relative_imports + +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: false diff --git a/lib/client.dart b/lib/client.dart new file mode 100644 index 0000000..8d056f0 --- /dev/null +++ b/lib/client.dart @@ -0,0 +1,5 @@ +export 'src/app_credentials.dart'; +export 'src/client.dart'; +export 'src/model/model.dart'; +export 'src/photos.dart'; +export 'src/dynamic_resize.dart'; diff --git a/lib/src/app_credentials.dart b/lib/src/app_credentials.dart new file mode 100644 index 0000000..33df1c7 --- /dev/null +++ b/lib/src/app_credentials.dart @@ -0,0 +1,63 @@ +import 'package:meta/meta.dart'; + +/// Credentials for accessing the unsplash api, as a registered app. +/// +/// You need to [register](https://unsplash.com/developers) as a developer +/// and create an usnplash app to get credentials. +@immutable +class AppCredentials { + /// Creates [AppCredentials] from the given arguments. + const AppCredentials({ + @required this.accessKey, + this.secretKey, + }) : assert(accessKey != null); + + /// The access key of the app these [AppCredentials] belong to. + final String accessKey; + + /// The secret key of the app these [AppCredentials] belong to. + final String secretKey; + + /// Creates a copy of this instance, replacing non `null` fields. + AppCredentials copyWith({ + String accessKey, + String secretKey, + }) { + return AppCredentials( + accessKey: accessKey ?? this.accessKey, + secretKey: secretKey ?? this.secretKey, + ); + } + + /// Serializes this instance to json. + Map toJson() { + return { + 'accessKey': accessKey, + 'secretKey': secretKey, + }; + } + + /// Deserializes [AppCredentials] from [json]. + factory AppCredentials.fromJson(Map json) { + return AppCredentials( + accessKey: json['accessKey'] as String, + secretKey: json['secretKey'] as String, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppCredentials && + runtimeType == other.runtimeType && + accessKey == other.accessKey && + secretKey == other.secretKey; + + @override + int get hashCode => accessKey.hashCode ^ secretKey.hashCode; + + @override + String toString() { + return 'Credentials{accessKey: $accessKey, secretKey: $secretKey}'; + } +} diff --git a/lib/src/client.dart b/lib/src/client.dart new file mode 100644 index 0000000..e84488c --- /dev/null +++ b/lib/src/client.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import '../client.dart'; +import 'app_credentials.dart'; +import 'exception.dart'; +import 'photos.dart'; + +class ClientSettings { + const ClientSettings({ + @required this.credentials, + }); + + final AppCredentials credentials; + final int maxPageSize = 30; +} + +class UnsplashClient { + UnsplashClient({@required this.settings}) : _http = http.Client(); + + final baseUrl = Uri.parse('https://api.unsplash.com/'); + + final http.Client _http; + + final ClientSettings settings; + + Photos get photos => Photos(this); +} + +typedef BodyDeserializer = T Function(dynamic body); + +class Request { + const Request({ + @required this.client, + @required this.request, + this.json, + @required this.isPublicAction, + this.bodyDeserializer, + }); + + final UnsplashClient client; + final http.Request request; + final dynamic json; + final bool isPublicAction; + final BodyDeserializer bodyDeserializer; + + Future> go() async { + final request = _prepareRequest(); + http.StreamedResponse response; + String body; + dynamic json; + T data; + dynamic error; + + try { + response = await client._http.send(request); + + body = await response.stream.bytesToString(); + + json = jsonDecode(body); + + if (bodyDeserializer != null) { + data = bodyDeserializer(json); + } + } catch (e, stackTrace) { + // ignore: avoid_catches_without_on_clauses + error = UnsplashClientException( + message: 'Failure while making request: $e', + cause: e, + stackTrace: stackTrace, + ); + } + + return Response( + request: this, + response: response, + error: error, + body: body, + json: json, + data: data, + ); + } + + http.Request _prepareRequest() { + final request = _copyRequest(); + + if (json != null) { + request.body = jsonEncode(json); + } + + request.headers.addAll(_createHeaders()); + + return request; + } + + http.Request _copyRequest() { + final request = http.Request(this.request.method, this.request.url); + request.headers.addAll(this.request.headers); + request.maxRedirects = this.request.maxRedirects; + request.followRedirects = this.request.followRedirects; + request.persistentConnection = this.request.persistentConnection; + request.encoding = this.request.encoding; + request.bodyBytes = this.request.bodyBytes; + return request; + } + + Map _createHeaders() { + final headers = {}; + + // API Version + headers.addAll({'Accept-Version': 'v1'}); + + // Auth + assert(isPublicAction); + headers.addAll(_publicActionAuthHeader(client.settings.credentials)); + + return headers; + } +} + +class Response { + Response({ + this.request, + this.response, + this.error, + this.body, + this.json, + this.data, + }); + + final Request request; + + final dynamic error; + + final http.BaseResponse response; + + final String body; + + final dynamic json; + + final T data; + + bool get isOk => error == null && response.statusCode < 400; + + bool get hasData => isOk && data != null; + + @override + String toString() { + return 'Response{isOk: $isOk, error: $error, ' + 'status: ${response?.statusCode}'; + } +} + +Map _publicActionAuthHeader(AppCredentials credentials) { + return {'Authorization': 'Client-ID ${credentials.accessKey}'}; +} + +Map _publicActionQueryParam(AppCredentials credentials) { + return {'client_id': credentials.accessKey}; +} diff --git a/lib/src/dynamic_resize.dart b/lib/src/dynamic_resize.dart new file mode 100644 index 0000000..5d7628c --- /dev/null +++ b/lib/src/dynamic_resize.dart @@ -0,0 +1,133 @@ +import 'utils.dart'; + +// ignore_for_file: public_member_api_docs + +/// The output format to convert the image to. +/// +/// See: [Imgix docs](https://docs.imgix.com/apis/url/format/fm) +enum ImageFormat { + gif, + jp2, + jpg, + json, + jxr, + pjpg, + mp4, + png, + png8, + png32, + webm, + webp, +} + +/// Controls how the output image is fit to its target dimensions after +/// resizing, and how any background areas will be filled. +/// +/// See: [Imgix docs](https://docs.imgix.com/apis/url/size/fit) +enum ResizeFitMode { + clamp, + clip, + crop, + facearea, + fill, + fillmax, + max, + min, + scale, +} + +/// Returns a new [Uri], Based on [photoUrl], under which a dynamically +/// resized version of the original photo can be accessed. +/// +/// {@macro unsplash.client.photo.resize} +/// +// TODO: auto=format: for automatically choosing the optimal image format +// depending on user browser +Uri resizePhotoUrl( + Uri photoUrl, { + int quality, + int width, + int height, + int devicePixelRatio, + ImageFormat format, + ResizeFitMode fit, + Map imgixParams, +}) { + assert(quality.isNull || quality >= 0 && quality <= 100); + assert(width.isNull || width >= 0); + assert(height.isNull || height >= 0); + assert(devicePixelRatio.isNull || + devicePixelRatio >= 0 && devicePixelRatio <= 8); + + // The officially supported params. + final params = { + 'q': quality?.toString(), + 'w': width?.toString(), + 'h': height?.toString(), + 'dpi': devicePixelRatio?.toString(), + 'fit': fit?.let(enumName), + 'fm': format?.let(enumName), + }; + + if (imgixParams != null) { + params.addAll(imgixParams); + } + + // Make sure the original query parameters are included for view tracking, + // as required by unsplash. + params.addAll(photoUrl.queryParameters); + + // Remove params whose value is null. + params.removeWhereValue(isNull); + + return photoUrl.replace(queryParameters: params); +} + +/// Creates photo urls which dynamically resize the original image. +extension DynamicResizeUrl on Uri { + /// Returns a new [Uri], Based on this photo url, under which a dynamically + /// resized version of the original photo can be accessed. + /// + /// {@template unsplash.client.photo.resize} + /// Unsplash supports dynamic resizing of photos. The transformations applied + /// to the original photo can be configured through a set of query parameters + /// in the requested url. + /// + /// The officially supported parameters are: + /// + /// - [width], [height] : for adjusting the width and height of a photo + /// - [crop] : for applying cropping to the photo + /// - [format] : for converting image format + /// - [quality] : for changing the compression quality when using lossy file + /// formats + /// - [fit] : for changing the fit of the image within the specified + /// dimensions + /// - [devicePixelRatio] : for adjusting the device pixel ratio of the image + /// + /// Under the hood unsplash uses [imgix](https://www.imgix.com/). The + /// [other parameters](https://docs.imgix.com/apis/url) offered by Imgix can be + /// used through [imgixParams], but unsplash dose not officially support them + /// and may remove support for them at any time in the future. + /// + /// See: [Unsplash docs](https://unsplash.com/documentation#supported-parameters) + /// {@endtemplate} + Uri resizePhoto({ + int quality, + int width, + int height, + int devicePixelRatio, + ImageFormat format, + ResizeFitMode fit, + Map imgixParams, + }) => + resizePhotoUrl( + this, + quality: quality, + width: width, + height: height, + devicePixelRatio: devicePixelRatio, + format: format, + fit: fit, + imgixParams: imgixParams, + ); +} diff --git a/lib/src/exception.dart b/lib/src/exception.dart new file mode 100644 index 0000000..6479323 --- /dev/null +++ b/lib/src/exception.dart @@ -0,0 +1,24 @@ +import 'package:meta/meta.dart'; +import 'client.dart'; + +/// An exception which is thrown when an error happens in the [UnsplashClient]. +class UnsplashClientException implements Exception { + const UnsplashClientException({ + @required this.message, + @required this.cause, + @required this.stackTrace, + }); + + final String message; + final dynamic cause; + final StackTrace stackTrace; + + @override + String toString() { + return ''' +$runtimeType: $message +$cause +$stackTrace +'''; + } +} diff --git a/lib/src/model/base_model.dart b/lib/src/model/base_model.dart new file mode 100644 index 0000000..ddad189 --- /dev/null +++ b/lib/src/model/base_model.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +// TODO document models + +@immutable +/// Base class for all models. +/// +/// [==] and [hashCode] is implemented based on the json representation, +/// returned by [toJson]. +/// +/// [toString] returns a pretty json representation based on [toJson]. +abstract class BaseModel { + /// Equality used to implement json representation of all models. + static const jsonEquality = DeepCollectionEquality(); + + /// Const constructor to allow sub classes to have const constructors. + const BaseModel(); + + /// Returns the json representation of this model in the unsplash api. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is BaseModel && + runtimeType == other.runtimeType && + jsonEquality.equals(toJson(), other.toJson()); + } + + @override + int get hashCode => jsonEquality.hash(toJson()); + + @override + String toString() { + return JsonEncoder.withIndent(' ').convert(toJson()); + } +} diff --git a/lib/src/model/collection.dart b/lib/src/model/collection.dart new file mode 100644 index 0000000..c99f3bb --- /dev/null +++ b/lib/src/model/collection.dart @@ -0,0 +1,105 @@ +import 'package:meta/meta.dart'; + +import '../utils.dart'; +import 'base_model.dart'; +import 'photo.dart'; +import 'user.dart'; + +// ignore_for_file: public_member_api_docs + +class Collection extends BaseModel { + final int id; + final String title; + final String description; + final DateTime publishedAt; + final DateTime updatedAt; + final Photo coverPhoto; + final User user; + final bool featured; + final int totalPhotos; + final bool private; + final String shareKey; + final CollectionLinks links; + + const Collection({ + @required this.id, + @required this.title, + @required this.description, + @required this.publishedAt, + @required this.updatedAt, + @required this.coverPhoto, + @required this.user, + @required this.featured, + @required this.totalPhotos, + @required this.private, + @required this.shareKey, + @required this.links, + }); + + @override + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'published_at': publishedAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'cover_photo': coverPhoto?.toJson(), + 'user': user?.toJson(), + 'featured': featured, + 'total_photos': totalPhotos, + 'private': private, + 'share_key': shareKey, + 'links': links?.toJson(), + }; + } + + factory Collection.fromJson(Map json) { + return Collection( + id: json['id'] as int, + title: json['title'] as String, + description: json['description'] as String, + publishedAt: (json['published_at'] as String)?.let(DateTime.parse), + updatedAt: (json['updated_at'] as String)?.let(DateTime.parse), + coverPhoto: (json['cover_photo'] as Map) + ?.let((it) => Photo.fromJson(it)), + user: (json['user'] as Map) + ?.let((it) => User.fromJson(it)), + featured: json['featured'] as bool, + totalPhotos: json['total_photos'] as int, + private: json['private'] as bool, + shareKey: json['shared_key'] as String, + links: (json['links'] as Map) + ?.let((it) => CollectionLinks.fromJson(it)), + ); + } +} + +class CollectionLinks extends BaseModel { + final Uri self; + final Uri html; + final Uri photos; + + const CollectionLinks({ + @required this.self, + @required this.html, + @required this.photos, + }); + + @override + Map toJson() { + return { + 'self': self?.toString(), + 'html': html?.toString(), + 'photos': photos?.toString(), + }; + } + + factory CollectionLinks.fromJson(Map json) { + return CollectionLinks( + self: (json['self'] as String)?.let(Uri.parse), + html: (json['html'] as String)?.let(Uri.parse), + photos: (json['photos'] as String)?.let(Uri.parse), + ); + } +} diff --git a/lib/src/model/location.dart b/lib/src/model/location.dart new file mode 100644 index 0000000..9fdc37b --- /dev/null +++ b/lib/src/model/location.dart @@ -0,0 +1,67 @@ +import 'package:meta/meta.dart'; + +import '../utils.dart'; +import 'base_model.dart'; + +// ignore_for_file: public_member_api_docs + +/// A geographical location. +class GeoLocation extends BaseModel { + const GeoLocation({ + @required this.name, + @required this.city, + @required this.country, + @required this.position, + }); + + final String name; + final String city; + final String country; + final GeoPosition position; + + @override + Map toJson() { + return { + 'name': name, + 'city': city, + 'country': country, + 'position': position, + }; + } + + factory GeoLocation.fromJson(Map json) { + return GeoLocation( + name: json['name'] as String, + city: json['city'] as String, + country: json['country'] as String, + position: (json['position'] as Map) + ?.let((it) => GeoPosition.fromJson(it)), + ); + } +} + +/// A precise geographical position on earth, in [latitude] and [longitude]. +class GeoPosition extends BaseModel { + const GeoPosition({ + @required this.latitude, + @required this.longitude, + }); + + final double latitude; + final double longitude; + + @override + Map toJson() { + return { + 'latitude': latitude, + 'longitude': longitude, + }; + } + + factory GeoPosition.fromJson(Map json) { + return GeoPosition( + latitude: json['latitude'] as double, + longitude: json['longitude'] as double, + ); + } +} diff --git a/lib/src/model/model.dart b/lib/src/model/model.dart new file mode 100644 index 0000000..01a264d --- /dev/null +++ b/lib/src/model/model.dart @@ -0,0 +1,5 @@ +export 'base_model.dart'; +export 'collection.dart'; +export 'location.dart'; +export 'photo.dart'; +export 'user.dart'; diff --git a/lib/src/model/photo.dart b/lib/src/model/photo.dart new file mode 100644 index 0000000..26887c8 --- /dev/null +++ b/lib/src/model/photo.dart @@ -0,0 +1,240 @@ +import 'package:meta/meta.dart'; + +import '../utils.dart'; +import 'base_model.dart'; +import 'collection.dart'; +import 'location.dart'; +import 'user.dart'; + +// ignore_for_file: public_member_api_docs + +class Photo extends BaseModel { + const Photo({ + @required this.id, + @required this.createdAt, + @required this.updatedAt, + @required this.urls, + @required this.width, + @required this.height, + @required this.color, + @required this.downloads, + @required this.likes, + @required this.likedByUser, + @required this.description, + @required this.exif, + @required this.location, + @required this.user, + @required this.currentUserCollections, + @required this.links, + @required this.tags, + }); + + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final Urls urls; + final int width; + final int height; + final String color; + final int downloads; + final int likes; + final bool likedByUser; + final String description; + final Exif exif; + final GeoLocation location; + final User user; + final List currentUserCollections; + final PhotoLinks links; + final List tags; + + double get ratio => width / height; + + @override + Map toJson() { + return { + 'id': id, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'urls': urls?.toJson(), + 'width': width, + 'height': height, + 'color': color, + 'downloads': downloads, + 'likes': likes, + 'liked_by_user': likedByUser, + 'description': description, + 'exif': exif?.toJson(), + 'location': location?.toJson(), + 'user': user?.toJson(), + 'current_user_collections': + currentUserCollections?.map((it) => it.toJson())?.toList(), + 'links': links?.toJson(), + 'tags': tags?.map((tag) => tag.toJson())?.toList(), + }; + } + + factory Photo.fromJson(Map json) { + return Photo( + id: json['id'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + urls: (json['urls'] as Map) + ?.let((it) => Urls.fromJson(it)), + width: json['width'] as int, + height: json['height'] as int, + color: json['color'] as String, + downloads: json['downloads'] as int, + likes: json['likes'] as int, + likedByUser: json['liked_by_user'] as bool, + description: json['description'] as String, + exif: (json['exif'] as Map) + ?.let((it) => Exif.fromJson(it)), + location: (json['location'] as Map) + ?.let((it) => GeoLocation.fromJson(it)), + user: User.fromJson(json['user'] as Map), + currentUserCollections: + (json['current_user_collections'] as List) + ?.cast>() + ?.map((it) => Collection.fromJson(it)) + ?.toList(), + links: (json['links'] as Map) + ?.let((it) => PhotoLinks.fromJson(it)), + tags: (json['tags'] as List) + ?.cast>() + ?.map((json) => Tag.fromJson(json)) + ?.toList() + ); + } +} + +class Tag extends BaseModel { + const Tag({ + @required this.title, + }); + + final String title; + + @override + Map toJson() { + return { + 'title': title, + }; + } + + factory Tag.fromJson(Map map) { + return Tag( + title: map['title'] as String, + ); + } +} + +class PhotoLinks extends BaseModel { + const PhotoLinks({ + @required this.self, + @required this.html, + @required this.download, + @required this.downloadLocation, + }); + + final Uri self; + final Uri html; + final Uri download; + final Uri downloadLocation; + + @override + Map toJson() { + return { + 'self': self?.toString(), + 'html': html?.toString(), + 'download': download?.toString(), + 'download_location': downloadLocation?.toString(), + }; + } + + factory PhotoLinks.fromJson(Map map) { + return PhotoLinks( + self: (map['self'] as String)?.let(Uri.parse), + html: (map['html'] as String)?.let(Uri.parse), + download: (map['download'] as String)?.let(Uri.parse), + downloadLocation: (map['download_location'] as String)?.let(Uri.parse), + ); + } +} + +class Exif extends BaseModel { + const Exif({ + @required this.make, + @required this.model, + @required this.exposureTime, + @required this.aperture, + @required this.focalLength, + @required this.iso, + }); + + final String make; + final String model; + final String exposureTime; + final String aperture; + final String focalLength; + final int iso; + + @override + Map toJson() { + return { + 'make': make, + 'model': model, + 'exposure_time': exposureTime, + 'aperture': aperture, + 'focal_length': focalLength, + 'iso': iso, + }; + } + + factory Exif.fromJson(Map json) { + return Exif( + make: json['make'] as String, + model: json['model'] as String, + exposureTime: json['exposure_time'] as String, + aperture: json['aperture'] as String, + focalLength: json['focal_length'] as String, + iso: json['iso'] as int, + ); + } +} + +class Urls extends BaseModel { + const Urls({ + @required this.raw, + @required this.full, + @required this.regular, + @required this.small, + @required this.thumb, + }); + + final Uri raw; + final Uri full; + final Uri regular; + final Uri small; + final Uri thumb; + + @override + Map toJson() { + return { + 'raw': raw?.toString(), + 'full': full?.toString(), + 'regular': regular?.toString(), + 'small': small?.toString(), + 'thumb': thumb?.toString(), + }; + } + + factory Urls.fromJson(Map json) { + return Urls( + raw: (json['raw'] as String).let(Uri.parse), + full: (json['full'] as String).let(Uri.parse), + regular: (json['regular'] as String).let(Uri.parse), + small: (json['small'] as String).let(Uri.parse), + thumb: (json['thumb'] as String).let(Uri.parse), + ); + } +} diff --git a/lib/src/model/user.dart b/lib/src/model/user.dart new file mode 100644 index 0000000..98a5791 --- /dev/null +++ b/lib/src/model/user.dart @@ -0,0 +1,218 @@ +import 'package:meta/meta.dart'; + +import '../utils.dart'; +import 'base_model.dart'; + +// ignore_for_file: public_member_api_docs + +class User extends BaseModel { + const User({ + @required this.id, + @required this.updatedAt, + @required this.username, + @required this.name, + @required this.firstName, + @required this.lastName, + @required this.email, + @required this.uploadsRemaining, + @required this.portfolioUrl, + @required this.bio, + @required this.location, + @required this.totalLikes, + @required this.totalPhotos, + @required this.totalCollections, + @required this.followedByUser, + @required this.followerCount, + @required this.followingCount, + @required this.downloads, + @required this.instagramUsername, + @required this.twitterUsername, + @required this.profileImage, + @required this.badge, + @required this.links, + }); + + final String id; + final DateTime updatedAt; + final String username; + final String name; + final String firstName; + final String lastName; + final String email; + final int uploadsRemaining; + final Uri portfolioUrl; + final String bio; + final String location; + final int totalLikes; + final int totalPhotos; + final int totalCollections; + final bool followedByUser; + final int followerCount; + final int followingCount; + final int downloads; + final String instagramUsername; + final String twitterUsername; + final ProfileImage profileImage; + final UserBadge badge; + final UserLinks links; + + @override + Map toJson() { + return { + 'id': id, + 'updated_at': updatedAt?.toIso8601String(), + 'username': username, + 'name': name, + 'first_name': firstName, + 'last_name': lastName, + 'email': email, + 'uploads_remaining': uploadsRemaining, + 'portfolio_url': portfolioUrl?.toString(), + 'bio': bio, + 'location': location, + 'total_likes': totalLikes, + 'total_photos': totalPhotos, + 'total_collections': totalCollections, + 'followed_by_user': followedByUser, + 'follower_count': followerCount, + 'following_count': followingCount, + 'downloads': downloads, + 'instagram_username': instagramUsername, + 'twitter_username': twitterUsername, + 'profile_image': profileImage?.toJson(), + 'badge': badge?.toJson(), + 'links': links?.toJson(), + }; + } + + factory User.fromJson(Map json) { + return User( + id: json['id'] as String, + updatedAt: DateTime.parse(json['updated_at'] as String), + username: json['username'] as String, + name: json['name'] as String, + firstName: json['first_name'] as String, + lastName: json['last_name'] as String, + email: json['email'] as String, + uploadsRemaining: json['uploads_remaining'] as int, + portfolioUrl: + (json['portfolio_url'] as String)?.let(Uri.parse), + bio: json['bio'] as String, + location: json['location'] as String, + totalLikes: json['total_likes'] as int, + totalPhotos: json['total_photos'] as int, + totalCollections: json['total_collections'] as int, + followedByUser: json['followed_by_user'] as bool, + followerCount: json['follower_count'] as int, + followingCount: json['following_count'] as int, + downloads: json['downloads'] as int, + instagramUsername: json['instagram_username'] as String, + twitterUsername: json['twitter_username'] as String, + profileImage: (json['profile_image'] as Map) + ?.let((it) => ProfileImage.fromJson(it)), + badge: (json['badge'] as Map) + ?.let((it) => UserBadge.fromJson(it)), + links: (json['links'] as Map) + ?.let((it) => UserLinks.fromJson(it)), + ); + } +} + +class ProfileImage extends BaseModel { + const ProfileImage({ + @required this.small, + @required this.medium, + @required this.large, + }); + + final Uri small; + final Uri medium; + final Uri large; + + @override + Map toJson() { + return { + 'small': small?.toString(), + 'medium': medium?.toString(), + 'large': large?.toString(), + }; + } + + factory ProfileImage.fromJson(Map json) { + return ProfileImage( + small: (json['small'] as String)?.let(Uri.parse), + medium: (json['medium'] as String)?.let(Uri.parse), + large: (json['large'] as String)?.let(Uri.parse), + ); + } +} + +class UserBadge extends BaseModel { + const UserBadge({ + @required this.title, + @required this.primary, + @required this.slug, + @required this.link, + }); + + final String title; + final bool primary; + final String slug; + final Uri link; + + @override + Map toJson() { + return { + 'title': title, + 'primary': primary, + 'slug': slug, + 'link': link?.toString(), + }; + } + + factory UserBadge.fromJson(Map json) { + return UserBadge( + title: json['title'] as String, + primary: json['primary'] as bool, + slug: json['slug'] as String, + link: (json['link'] as String)?.let(Uri.parse), + ); + } +} + +class UserLinks extends BaseModel { + const UserLinks({ + @required this.self, + @required this.html, + @required this.photos, + @required this.likes, + @required this.portfolio, + }); + + final Uri self; + final Uri html; + final Uri photos; + final Uri likes; + final Uri portfolio; + + @override + Map toJson() { + return { + 'self': self?.toString(), + 'html': html?.toString(), + 'photos': photos?.toString(), + 'likes': likes?.toString(), + 'portfolio': portfolio?.toString(), + }; + } + + factory UserLinks.fromJson(Map json) { + return UserLinks( + self: (json['self'] as String)?.let(Uri.parse), + html: (json['html'] as String)?.let(Uri.parse), + photos: (json['photos'] as String)?.let(Uri.parse), + likes: (json['likes'] as String)?.let(Uri.parse), + portfolio: (json['portfolio'] as String)?.let(Uri.parse), + ); + } +} diff --git a/lib/src/photos.dart b/lib/src/photos.dart new file mode 100644 index 0000000..f730e00 --- /dev/null +++ b/lib/src/photos.dart @@ -0,0 +1,95 @@ +import 'package:http/http.dart' as http; +import 'package:unsplash_client/client.dart'; + +import 'client.dart'; +import 'model/photo.dart'; +import 'utils.dart'; + +enum PhotoOrder { latest, oldest, popular } +enum PhotoOrientation { landscape, portrait, squarish } + +class Photos { + Photos(this.client) + : assert(client != null), + baseUrl = client.baseUrl.resolve('photos/'); + + final UnsplashClient client; + + final Uri baseUrl; + + /// Get a single page from the list of all photos. + /// + /// See: + /// + /// - [Unsplash docs](https://unsplash.com/documentation#list-photos) + Request> list({ + int page, + int perPage, + PhotoOrder orderBy, + }) { + if (page != null) { + assert(page >= 0); + } + if (perPage != null) { + assert(perPage >= 0 && perPage <= client.settings.maxPageSize); + } + + final params = { + 'page': page?.toString(), + 'per_page': perPage?.toString(), + 'oder_by': orderBy?.let(enumName), + }; + + params.removeWhereValue(isNull); + + final url = baseUrl.replace(queryParameters: params); + + return Request( + client: client, + request: http.Request('GET', url), + isPublicAction: true, + bodyDeserializer: _deserializePhotos, + ); + } + + /// Retrieve a one or more random photos, given optional filters. + /// + /// See: + /// + /// - [Unsplash docs](https://unsplash.com/documentation#list-photos) + Request> random({ + String query, + String username, + bool featured, + Iterable collections, + PhotoOrientation orientation, + int count = 1, + }) { + assert(count != null); + assert(count >= 0 && count <= client.settings.maxPageSize); + + final params = { + 'query': query, + 'username': username, + 'featured': featured?.toString(), + 'collections': collections?.join(','), + 'orientation': orientation?.let(enumName), + 'count': count.toString(), + }; + + params.removeWhereValue(isNull); + + final url = baseUrl.resolve('random').replace(queryParameters: params); + + return Request( + client: client, + request: http.Request('GET', url), + isPublicAction: true, + bodyDeserializer: _deserializePhotos, + ); + } +} + +List _deserializePhotos(dynamic body) { + return deserializeList(body, (json) => Photo.fromJson(json)); +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..64c6034 --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,28 @@ + +String enumName(dynamic e) => e.toString().split('.')[1]; + +/// Whether [t] is `null`. +bool isNull(T t) => t == null; + +/// Util extensions on all objects. +extension ObjectUtils on T { + /// Transforms this, through the [Function] [f], to [E]. + E let(E Function(T t) f) => f(this); + + /// Whether this is `null`. + bool get isNull => this == null; + + /// Whether this is not `null`. + bool get isNotNull => this != null; +} + +extension MapUtils on Map { + void removeWhereValue(bool Function(V v) f) => removeWhere((_, v) => f(v)); +} + +List deserializeList( + dynamic json, + T Function(Map json) f, + ) { + return (json as List).cast>().map(f).toList(); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5b4b710 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,17 @@ +name: unsplash_client +description: An unofficial client to for the unsplash api. +version: 0.1.0 + +environment: + sdk: '>=2.7.0 <3.0.0' + +dependencies: + meta: ^1.1.8 + http: ^0.12.0+4 + collection: ^1.14.12 + +dev_dependencies: + effective_dart: ^1.2.0 + test: ^1.6.0 + matcher: ^0.12.6 + path: ^1.6.4 diff --git a/test/photos_test.dart b/test/photos_test.dart new file mode 100644 index 0000000..a47f790 --- /dev/null +++ b/test/photos_test.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:unsplash_client/client.dart'; + +import 'test_utils.dart'; + +void main() { + AppCredentials credentials; + UnsplashClient client; + + setUpAll(() async { + final credentialsFile = File('./test-unsplash-credentials.json'); + credentials = await readAppCredentials(credentialsFile); + + client = UnsplashClient(settings: ClientSettings(credentials: credentials)); + }); + + group('Photos', () { + test('list', () async { + final photos = await client.photos.list(perPage: 2).go(); + + print(photos.data); + + expect(photos.data, hasLength(2)); + }); + + test('random', () async { + final photos = await client.photos.random(count: 2).go(); + + print(photos.data); + + expect(photos.data, hasLength(2)); + }); + }); +} diff --git a/test/test_utils.dart b/test/test_utils.dart new file mode 100644 index 0000000..cdba10f --- /dev/null +++ b/test/test_utils.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:unsplash_client/client.dart'; + +// ignore_for_file: avoid_catches_without_on_clauses + +/// Reads a [File] as [AppCredentials]. +Future readAppCredentials(File file) async { + try { + final json = await _readJsonFile(file) as Map; + return AppCredentials.fromJson(json); + } catch (e) { + throw Exception('Could not read $file as AppCredentials: $e'); + } +} + +Future _readJsonFile(File file) async { + try { + final fileContents = await file.readAsString(); + return jsonDecode(fileContents); + } catch (e) { + throw Exception('Could not read $file as json: $e'); + } +}