├── ios ├── Flutter │ ├── Debug.xcconfig │ ├── Release.xcconfig │ └── AppFrameworkInfo.plist ├── Runner │ ├── Runner-Bridging-Header.h │ ├── Assets.xcassets │ │ ├── LaunchImage.imageset │ │ │ ├── LaunchImage.png │ │ │ ├── LaunchImage@2x.png │ │ │ ├── LaunchImage@3x.png │ │ │ ├── README.md │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ ├── Icon-App-20x20@1x.png │ │ │ ├── Icon-App-20x20@2x.png │ │ │ ├── Icon-App-20x20@3x.png │ │ │ ├── Icon-App-29x29@1x.png │ │ │ ├── Icon-App-29x29@2x.png │ │ │ ├── Icon-App-29x29@3x.png │ │ │ ├── Icon-App-40x40@1x.png │ │ │ ├── Icon-App-40x40@2x.png │ │ │ ├── Icon-App-40x40@3x.png │ │ │ ├── Icon-App-60x60@2x.png │ │ │ ├── Icon-App-60x60@3x.png │ │ │ ├── Icon-App-76x76@1x.png │ │ │ ├── Icon-App-76x76@2x.png │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ └── Contents.json │ ├── AppDelegate.swift │ ├── Info.plist │ └── Base.lproj │ │ ├── Main.storyboard │ │ └── LaunchScreen.storyboard ├── Runner.xcworkspace │ └── contents.xcworkspacedata └── Runner.xcodeproj │ ├── project.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── Runner.xcscheme ├── lib ├── models │ ├── models.dart │ ├── feed.dart │ ├── subreddit.dart │ ├── photo.dart │ ├── feed.g.dart │ └── subreddit.g.dart ├── api │ ├── response_models │ │ ├── response_models.dart │ │ ├── serializers.dart │ │ ├── serializers.g.dart │ │ ├── subreddit_response.dart │ │ └── link_listing_response.dart │ ├── api.dart │ ├── mappers │ │ ├── subreddit_info_mapper.dart │ │ └── link_listing_photos_mapper.dart │ ├── api_repository │ │ ├── local.dart │ │ ├── facade.dart │ │ └── api.dart │ └── reddit_repository.dart ├── consts.dart ├── screens │ ├── screens.dart │ ├── photo_preview.dart │ ├── feed_tab.dart │ ├── preferences_sheet.dart │ ├── main.dart │ └── import_subscriptions.dart ├── widgets │ ├── state_aware │ │ ├── preference_view_model.dart │ │ ├── show_nsfw_preference_tile.dart │ │ └── dark_theme_preference_tile.dart │ ├── reddigram_logo.dart │ ├── widgets.dart │ ├── nsfw_badge.dart │ ├── subreddit_circle_avatar.dart │ ├── preferences_provider.dart │ ├── subreddit_list_tile.dart │ ├── nsfw_overlay.dart │ ├── infinite_list.dart │ ├── icon_navigation_bar.dart │ ├── upvoteable.dart │ └── photo_grid_item.dart ├── utils │ └── jwt.dart ├── store │ ├── auth │ │ ├── reducer.dart │ │ ├── auth_state.dart │ │ ├── actions.dart │ │ └── auth_state.g.dart │ ├── suggested_subscriptions │ │ ├── reducer.dart │ │ └── actions.dart │ ├── subreddits │ │ ├── reducer.dart │ │ └── actions.dart │ ├── subreddits_search │ │ ├── subreddits_search_state.dart │ │ ├── reducer.dart │ │ ├── actions.dart │ │ └── subreddits_search_state.g.dart │ ├── reducer.dart │ ├── store.dart │ ├── feeds │ │ ├── reducer.dart │ │ └── actions.dart │ ├── preferences │ │ ├── preferences_state.dart │ │ ├── reducer.dart │ │ ├── actions.dart │ │ └── preferences_state.g.dart │ ├── photos │ │ ├── actions.dart │ │ └── reducer.dart │ ├── subscriptions │ │ ├── reducer.dart │ │ └── actions.dart │ ├── app_state.dart │ └── app_state.g.dart ├── main.dart ├── theme.dart └── app.dart ├── media └── screens_preview.jpg ├── assets ├── chrome_desktop_site.png └── fonts │ └── Pacifico │ ├── Pacifico-Regular.ttf │ └── OFL.txt ├── android ├── gradle.properties ├── app │ ├── src │ │ └── main │ │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ └── styles.xml │ │ │ ├── drawable │ │ │ │ ├── launch_background.xml │ │ │ │ └── ic_file_download.xml │ │ │ └── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── kotlin │ │ │ └── me │ │ │ │ └── wolszon │ │ │ │ └── reddigram │ │ │ │ ├── ReddigramApplication.kt │ │ │ │ └── MainActivity.kt │ │ │ └── AndroidManifest.xml │ ├── proguard-rules.pro │ ├── google-services.json │ └── build.gradle ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── settings.gradle └── build.gradle ├── test ├── repository_test.dart └── jwt_utils_test.dart ├── .metadata ├── README.md ├── pubspec.yaml ├── .gitignore └── LICENSE /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" -------------------------------------------------------------------------------- /lib/models/models.dart: -------------------------------------------------------------------------------- 1 | export 'photo.dart'; 2 | export 'feed.dart'; 3 | export 'subreddit.dart'; -------------------------------------------------------------------------------- /media/screens_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/media/screens_preview.jpg -------------------------------------------------------------------------------- /assets/chrome_desktop_site.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/assets/chrome_desktop_site.png -------------------------------------------------------------------------------- /assets/fonts/Pacifico/Pacifico-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/assets/fonts/Pacifico/Pacifico-Regular.ttf -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.enableR8=true 3 | android.useAndroidX=true 4 | android.enableJetifier=true 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /lib/api/response_models/response_models.dart: -------------------------------------------------------------------------------- 1 | export 'link_listing_response.dart'; 2 | export 'serializers.dart'; 3 | export 'subreddit_response.dart'; 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /lib/consts.dart: -------------------------------------------------------------------------------- 1 | class GlanceConsts { 2 | static const String oauthClientId = 'kLJBLghY79EbfQ'; 3 | static const String oauthRedirectUrl = 'reddigram://redirect'; 4 | } -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Albert221/Glance/HEAD/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/screens/screens.dart: -------------------------------------------------------------------------------- 1 | export 'feed_tab.dart'; 2 | export 'import_subscriptions.dart'; 3 | export 'main.dart'; 4 | export 'photo_preview.dart'; 5 | export 'preferences_sheet.dart'; 6 | export 'subreddit.dart'; 7 | export 'subscriptions_tab.dart'; 8 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip 7 | -------------------------------------------------------------------------------- /test/repository_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:reddigram/api/api.dart'; 3 | 4 | void main() { 5 | test('Repository fetches and maps stuff', () async { 6 | final listing = await RedditRepository().feed('r/EarthPorn'); 7 | 8 | print(listing); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 88fa7ea4031f5c86225573e58e5558dc4ea1c251 8 | channel: beta 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /lib/widgets/state_aware/preference_view_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | class PreferenceViewModel { 4 | final T value; 5 | final void Function(bool) onSwitch; 6 | 7 | PreferenceViewModel({@required this.value, @required this.onSwitch}) 8 | : assert(value != null), 9 | assert(onSwitch != null); 10 | } 11 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Flutter Wrapper 2 | -keep class io.flutter.app.** { *; } 3 | -keep class io.flutter.plugin.** { *; } 4 | -keep class io.flutter.util.** { *; } 5 | -keep class io.flutter.view.** { *; } 6 | -keep class io.flutter.** { *; } 7 | -keep class io.flutter.plugins.** { *; } 8 | 9 | -dontwarn android.arch.lifecycle.DefaultLifecycleObserver 10 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_file_download.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /test/jwt_utils_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:reddigram/utils/jwt.dart'; 3 | 4 | void main() { 5 | test('Repository fetches and maps stuff', () async { 6 | final token = 7 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTYzNzk1Mjd9.ahhv8_mBIYfdnrTUqyrgY4ABT5riU-_xACYQ0hQ0fcI'; 8 | expect(jwtExp(token), DateTime.utc(2019, 4, 27, 15, 38, 47)); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /lib/widgets/reddigram_logo.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class ReddigramLogo extends StatelessWidget { 4 | const ReddigramLogo({Key key}) : super(key: key); 5 | 6 | @override 7 | Widget build(BuildContext context) { 8 | return Text( 9 | 'Glance', 10 | style: TextStyle( 11 | fontFamily: 'Pacifico', 12 | fontSize: 22.0, 13 | fontWeight: FontWeight.normal, 14 | ), 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/api/api.dart: -------------------------------------------------------------------------------- 1 | export 'reddit_repository.dart'; 2 | 3 | export 'api_repository/api.dart'; 4 | export 'api_repository/local.dart'; 5 | export 'api_repository/facade.dart'; 6 | 7 | export 'mappers/link_listing_photos_mapper.dart'; 8 | export 'mappers/subreddit_info_mapper.dart'; 9 | 10 | import 'package:reddigram/api/api.dart'; 11 | 12 | final redditRepository = RedditRepository(); 13 | final apiRepository = ApiRepositoriesFacade( 14 | fetchRedditAccessToken: redditRepository.getAccessToken, 15 | ); 16 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/api/response_models/serializers.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/standard_json_plugin.dart'; 2 | import 'package:built_value/serializer.dart'; 3 | import 'package:built_collection/built_collection.dart'; 4 | import 'package:reddigram/api/response_models/response_models.dart'; 5 | 6 | part 'serializers.g.dart'; 7 | 8 | @SerializersFor(const [ 9 | LinkListingResponse, 10 | SubredditListResponse, 11 | SubredditListResponse, 12 | ]) 13 | final Serializers serializers = 14 | (_$serializers.toBuilder()..addPlugin(StandardJsonPlugin())).build(); 15 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() 4 | 5 | def plugins = new Properties() 6 | def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') 7 | if (pluginsFile.exists()) { 8 | pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } 9 | } 10 | 11 | plugins.each { name, path -> 12 | def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() 13 | include ":$name" 14 | project(":$name").projectDir = pluginDirectory 15 | } 16 | -------------------------------------------------------------------------------- /lib/utils/jwt.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:jaguar_jwt/jaguar_jwt.dart'; 4 | 5 | DateTime jwtExp(String token) { 6 | try { 7 | final split = token.split('.'); 8 | final encodedPayload = split[1]; 9 | final decodedPayload = B64urlEncRfc7515.decodeUtf8(encodedPayload); 10 | 11 | final payload = json.decode(decodedPayload); 12 | 13 | return DateTime.fromMillisecondsSinceEpoch( 14 | payload['exp'] * 1000, 15 | isUtc: true, 16 | ); 17 | } on Exception catch (_) { 18 | return null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/widgets/widgets.dart: -------------------------------------------------------------------------------- 1 | export 'state_aware/dark_theme_preference_tile.dart'; 2 | export 'state_aware/show_nsfw_preference_tile.dart'; 3 | export 'state_aware/preference_view_model.dart'; 4 | export 'icon_navigation_bar.dart'; 5 | export 'infinite_list.dart'; 6 | export 'nsfw_badge.dart'; 7 | export 'nsfw_overlay.dart'; 8 | export 'photo_grid_item.dart'; 9 | export 'photo_list_item.dart'; 10 | export 'preferences_provider.dart'; 11 | export 'reddigram_logo.dart'; 12 | export 'subreddit_circle_avatar.dart'; 13 | export 'subreddit_list_tile.dart'; 14 | export 'upvoteable.dart'; 15 | -------------------------------------------------------------------------------- /lib/store/auth/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/store/store.dart'; 2 | import 'package:redux/redux.dart'; 3 | 4 | Reducer authStateReducer = combineReducers([ 5 | TypedReducer(_setUsername), 6 | TypedReducer(_setAuthStatus), 7 | ]); 8 | 9 | AuthState _setUsername(AuthState state, SetUsername action) { 10 | return state.rebuild((b) => b..username = action.username); 11 | } 12 | 13 | AuthState _setAuthStatus(AuthState state, SetAuthStatus action) { 14 | return state.rebuild((b) => b..status = action.status); 15 | } 16 | -------------------------------------------------------------------------------- /lib/store/suggested_subscriptions/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:reddigram/store/store.dart'; 3 | import 'package:redux/redux.dart'; 4 | 5 | Reducer> suggestedSubscriptionsReducer = combineReducers([ 6 | TypedReducer, FetchedSuggestedSubscriptions>( 7 | _fetchedSuggestedSubscriptions), 8 | ]); 9 | 10 | BuiltSet _fetchedSuggestedSubscriptions( 11 | BuiltSet state, FetchedSuggestedSubscriptions action) { 12 | return BuiltSet.from(action.suggestedSubscriptions); 13 | } 14 | -------------------------------------------------------------------------------- /lib/models/feed.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | 4 | part 'feed.g.dart'; 5 | 6 | abstract class Feed implements Built { 7 | String get name; 8 | 9 | BuiltList get photosIds; 10 | 11 | Feed._(); 12 | 13 | factory Feed([updates(FeedBuilder b)]) { 14 | return _$Feed._( 15 | name: '', 16 | photosIds: BuiltList(), 17 | ) 18 | .rebuild(updates); 19 | } 20 | 21 | factory Feed.blank(String name) { 22 | return Feed().rebuild((b) => b..name = name); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/store/subreddits/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:reddigram/models/models.dart'; 3 | import 'package:reddigram/store/store.dart'; 4 | import 'package:redux/redux.dart'; 5 | 6 | Reducer> subredditsReducer = combineReducers([ 7 | TypedReducer, FetchedSubreddits>( 8 | _fetchedSubreddits), 9 | ]); 10 | 11 | BuiltMap _fetchedSubreddits( 12 | BuiltMap state, FetchedSubreddits action) { 13 | return state 14 | .rebuild((b) => action.subreddits.forEach((sub) => b[sub.id] = sub)); 15 | } 16 | -------------------------------------------------------------------------------- /lib/store/subreddits_search/subreddits_search_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | 4 | part 'subreddits_search_state.g.dart'; 5 | 6 | abstract class SubredditsSearchState 7 | implements Built { 8 | String get lastQuery; 9 | 10 | BuiltList get resultFeedsIds; 11 | 12 | SubredditsSearchState._(); 13 | 14 | factory SubredditsSearchState([updates(SubredditsSearchStateBuilder b)]) { 15 | return _$SubredditsSearchState._( 16 | lastQuery: '', 17 | resultFeedsIds: BuiltList([]), 18 | ).rebuild(updates); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/widgets/nsfw_badge.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class NsfwBadge extends StatelessWidget { 4 | const NsfwBadge({Key key}) : super(key: key); 5 | 6 | @override 7 | Widget build(BuildContext context) { 8 | return Container( 9 | margin: const EdgeInsets.only(right: 8.0), 10 | decoration: BoxDecoration( 11 | borderRadius: BorderRadius.circular(4.0), 12 | color: Colors.grey, 13 | ), 14 | padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 4.0), 15 | child: const Text( 16 | '18+', 17 | style: TextStyle( 18 | fontSize: 14.0, 19 | color: Colors.white, 20 | ), 21 | ), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/widgets/subreddit_circle_avatar.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:reddigram/models/models.dart'; 4 | 5 | class SubredditCircleAvatar extends StatelessWidget { 6 | final Subreddit subreddit; 7 | 8 | const SubredditCircleAvatar({Key key, @required this.subreddit}) 9 | : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return CircleAvatar( 14 | backgroundColor: subreddit.primaryColorMapped ?? Colors.transparent, 15 | backgroundImage: subreddit.iconUrl.isNotEmpty 16 | ? CachedNetworkImageProvider(subreddit.iconUrl) 17 | : null, 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/widgets/preferences_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:reddigram/store/store.dart'; 3 | 4 | class PreferencesProvider extends InheritedWidget { 5 | final PreferencesState preferences; 6 | final Widget child; 7 | 8 | PreferencesProvider({@required this.preferences, @required this.child}) 9 | : assert(preferences != null), 10 | assert(child != null); 11 | 12 | static PreferencesState of(BuildContext context) { 13 | return (context.inheritFromWidgetOfExactType(PreferencesProvider) 14 | as PreferencesProvider) 15 | .preferences; 16 | } 17 | 18 | @override 19 | bool updateShouldNotify(PreferencesProvider oldWidget) => 20 | preferences != oldWidget.preferences; 21 | } 22 | -------------------------------------------------------------------------------- /lib/store/subreddits_search/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/store/store.dart'; 2 | import 'package:redux/redux.dart'; 3 | 4 | Reducer subredditsSearchReducer = combineReducers([ 5 | TypedReducer( 6 | _setSubredditsSearchResult), 7 | TypedReducer(_clearSearch), 8 | ]); 9 | 10 | SubredditsSearchState _setSubredditsSearchResult( 11 | SubredditsSearchState state, FetchedSearchSubreddits action) { 12 | return state.rebuild((b) => b 13 | ..lastQuery = action.query 14 | ..resultFeedsIds.replace(action.resultSubredditsIds)); 15 | } 16 | 17 | SubredditsSearchState _clearSearch( 18 | SubredditsSearchState state, ClearSearch action) { 19 | return SubredditsSearchState(); 20 | } 21 | -------------------------------------------------------------------------------- /lib/store/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/store/store.dart'; 2 | 3 | ReddigramState rootReducer(ReddigramState state, action) { 4 | return state.rebuild((b) => b 5 | ..authState.replace(authStateReducer(state.authState, action)) 6 | ..preferences.replace(preferencesReducer(state.preferences, action)) 7 | ..photos.replace(photosReducer(state.photos, action)) 8 | ..feeds.replace(feedsReducer(state.feeds, action)) 9 | ..subreddits.replace(subredditsReducer(state.subreddits, action)) 10 | ..subscriptions.replace(subscriptionsReducer(state.subscriptions, action)) 11 | ..suggestedSubscriptions.replace( 12 | suggestedSubscriptionsReducer(state.suggestedSubscriptions, action)) 13 | ..subredditsSearch 14 | .replace(subredditsSearchReducer(state.subredditsSearch, action))); 15 | } 16 | -------------------------------------------------------------------------------- /lib/api/mappers/subreddit_info_mapper.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/api/response_models/response_models.dart'; 2 | import 'package:reddigram/models/models.dart' as models; 3 | 4 | class SubredditInfoMapper { 5 | static models.Subreddit map(SubredditResponse response) { 6 | return models.Subreddit((b) => b 7 | ..id = response.data.name 8 | ..name = response.data.displayName 9 | ..nsfw = response.data.nsfw ?? false 10 | ..primaryColor = response.data.primaryColor ?? '' 11 | ..iconUrl = response.data.iconUrl ?? '' 12 | ..submissionType = response.data.submissionType ?? ''); 13 | } 14 | 15 | static List mapList(SubredditListResponse response) { 16 | return response.data.children 17 | .skipWhile((feed) => feed.data.subredditType == 'private') 18 | .map(map) 19 | .toList(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_crashlytics/firebase_crashlytics.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:reddigram/app.dart'; 4 | import 'package:reddigram/store/store.dart'; 5 | import 'package:redux/redux.dart'; 6 | import 'package:redux_thunk/redux_thunk.dart' show thunkMiddleware; 7 | 8 | void main() { 9 | FlutterError.onError = (details) { 10 | FlutterError.dumpErrorToConsole(details); 11 | Crashlytics.instance.onError(details); 12 | }; 13 | 14 | final store = Store( 15 | rootReducer, 16 | initialState: ReddigramState(), 17 | middleware: [ 18 | (Store store, action, NextDispatcher next) { 19 | debugPrint(action.toString()); 20 | 21 | next(action); 22 | }, 23 | thunkMiddleware 24 | ], 25 | ); 26 | 27 | runApp(ReddigramApp(store: store)); 28 | } 29 | -------------------------------------------------------------------------------- /lib/store/store.dart: -------------------------------------------------------------------------------- 1 | export 'auth/actions.dart'; 2 | export 'auth/auth_state.dart'; 3 | export 'auth/reducer.dart'; 4 | 5 | export 'feeds/actions.dart'; 6 | export 'feeds/reducer.dart'; 7 | 8 | export 'photos/actions.dart'; 9 | export 'photos/reducer.dart'; 10 | 11 | export 'preferences/actions.dart'; 12 | export 'preferences/preferences_state.dart'; 13 | export 'preferences/reducer.dart'; 14 | 15 | export 'subreddits/actions.dart'; 16 | export 'subreddits/reducer.dart'; 17 | 18 | export 'subreddits_search/subreddits_search_state.dart'; 19 | export 'subreddits_search/reducer.dart'; 20 | export 'subreddits_search/actions.dart'; 21 | 22 | export 'subscriptions/actions.dart'; 23 | export 'subscriptions/reducer.dart'; 24 | 25 | export 'suggested_subscriptions/actions.dart'; 26 | export 'suggested_subscriptions/reducer.dart'; 27 | 28 | export 'app_state.dart'; 29 | export 'reducer.dart'; 30 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.3.31' 3 | repositories { 4 | google() 5 | jcenter() 6 | maven { url 'https://maven.fabric.io/public' } 7 | } 8 | 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:3.3.2' 11 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 12 | classpath 'com.google.gms:google-services:3.2.1' 13 | classpath 'io.fabric.tools:gradle:1.26.1' 14 | } 15 | } 16 | 17 | allprojects { 18 | repositories { 19 | google() 20 | jcenter() 21 | } 22 | } 23 | 24 | rootProject.buildDir = '../build' 25 | subprojects { 26 | project.buildDir = "${rootProject.buildDir}/${project.name}" 27 | } 28 | subprojects { 29 | project.evaluationDependsOn(':app') 30 | } 31 | 32 | task clean(type: Delete) { 33 | delete rootProject.buildDir 34 | } 35 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/store/feeds/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:reddigram/models/models.dart'; 3 | import 'package:reddigram/store/store.dart'; 4 | import 'package:redux/redux.dart'; 5 | 6 | Reducer> feedsReducer = combineReducers([ 7 | TypedReducer, FetchedFreshFeed>(_fetchedFreshFeed), 8 | TypedReducer, FetchedMoreFeed>(_fetchedMoreFeed), 9 | ]); 10 | 11 | BuiltMap _fetchedFreshFeed( 12 | BuiltMap state, FetchedFreshFeed action) { 13 | return state.rebuild((b) => b[action.name] = action.feed); 14 | } 15 | 16 | BuiltMap _fetchedMoreFeed( 17 | BuiltMap state, FetchedMoreFeed action) { 18 | return state.rebuild((b) => b.updateValue(action.name, 19 | (b) => b.rebuild((b) => b.photosIds.addAll(action.photosIds)))); 20 | } 21 | -------------------------------------------------------------------------------- /lib/store/suggested_subscriptions/actions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:reddigram/api/api.dart'; 4 | import 'package:reddigram/store/store.dart'; 5 | import 'package:redux/redux.dart'; 6 | import 'package:redux_thunk/redux_thunk.dart'; 7 | 8 | ThunkAction fetchSuggestedSubscriptions([Completer completer]) { 9 | return (Store store) { 10 | apiRepository 11 | .suggestedSubreddits(store.state.subscriptions.toList()) 12 | .then((suggestions) { 13 | store.dispatch(FetchedSuggestedSubscriptions(suggestions)); 14 | store.dispatch(fetchSubreddits(suggestions)); 15 | }) 16 | .whenComplete(() => completer?.complete()); 17 | }; 18 | } 19 | 20 | class FetchedSuggestedSubscriptions { 21 | final List suggestedSubscriptions; 22 | 23 | FetchedSuggestedSubscriptions(this.suggestedSubscriptions); 24 | } 25 | -------------------------------------------------------------------------------- /lib/widgets/state_aware/show_nsfw_preference_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_redux/flutter_redux.dart'; 3 | import 'package:reddigram/store/store.dart'; 4 | import 'package:reddigram/widgets/widgets.dart'; 5 | 6 | class ShowNsfwPreferenceTile extends StatelessWidget { 7 | const ShowNsfwPreferenceTile({Key key}) : super(key: key); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return StoreConnector( 12 | converter: (store) => PreferenceViewModel( 13 | value: store.state.preferences.showNsfw, 14 | onSwitch: (showNsfw) => store.dispatch(setShowNsfw(showNsfw)), 15 | ), 16 | builder: (context, vm) => SwitchListTile( 17 | title: const Text('Show adult content'), 18 | secondary: const Icon(Icons.block), 19 | value: vm.value, 20 | onChanged: vm.onSwitch, 21 | ), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/store/preferences/preferences_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | 4 | part 'preferences_state.g.dart'; 5 | 6 | abstract class PreferencesState 7 | implements Built { 8 | AppTheme get theme; 9 | 10 | bool get showNsfw; 11 | 12 | bool get cutLongPhotos; 13 | 14 | PreferencesState._(); 15 | 16 | factory PreferencesState([updates(PreferencesStateBuilder b)]) { 17 | return _$PreferencesState._( 18 | theme: AppTheme.light, 19 | showNsfw: false, 20 | cutLongPhotos: false, 21 | ).rebuild(updates); 22 | } 23 | } 24 | 25 | class AppTheme extends EnumClass { 26 | static const AppTheme light = _$light; 27 | static const AppTheme dark = _$dark; 28 | 29 | const AppTheme._(String name) : super(name); 30 | 31 | static BuiltSet get values => _$values; 32 | 33 | static AppTheme valueOf(String name) => _$valueOf(name); 34 | } 35 | -------------------------------------------------------------------------------- /lib/api/api_repository/local.dart: -------------------------------------------------------------------------------- 1 | import 'package:shared_preferences/shared_preferences.dart'; 2 | 3 | class ApiLocalRepository { 4 | static const preferencesKey = "local_subscriptions"; 5 | 6 | Future> fetchSubscriptions() { 7 | return SharedPreferences.getInstance() 8 | .then((prefs) => prefs.getStringList(preferencesKey) ?? []); 9 | } 10 | 11 | Future subscribeSubreddit(String name) { 12 | return SharedPreferences.getInstance().then((prefs) { 13 | final subs = prefs.getStringList(preferencesKey) ?? []; 14 | 15 | if (!subs.contains(name)) { 16 | subs.add(name); 17 | prefs.setStringList(preferencesKey, subs); 18 | } 19 | }); 20 | } 21 | 22 | Future unsubscribeSubreddit(String name) { 23 | return SharedPreferences.getInstance().then((prefs) { 24 | final subs = prefs.getStringList(preferencesKey) ?? []; 25 | 26 | subs.removeWhere((sub) => sub == name); 27 | prefs.setStringList(preferencesKey, subs); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/widgets/state_aware/dark_theme_preference_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_redux/flutter_redux.dart'; 3 | import 'package:reddigram/store/store.dart'; 4 | import 'package:reddigram/widgets/widgets.dart'; 5 | 6 | class DarkThemePreferenceTile extends StatelessWidget { 7 | const DarkThemePreferenceTile({Key key}) : super(key: key); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | return StoreConnector( 12 | converter: (store) => PreferenceViewModel( 13 | value: store.state.preferences.theme, 14 | onSwitch: (dark) => 15 | store.dispatch(setTheme(dark ? AppTheme.dark : AppTheme.light)), 16 | ), 17 | builder: (context, vm) => SwitchListTile( 18 | title: const Text('Dark theme'), 19 | secondary: const Icon(Icons.invert_colors), 20 | value: vm.value == AppTheme.dark, 21 | onChanged: vm.onSwitch, 22 | ), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/store/photos/actions.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/api/api.dart'; 2 | import 'package:reddigram/models/models.dart'; 3 | import 'package:reddigram/store/store.dart'; 4 | import 'package:redux/redux.dart'; 5 | import 'package:redux_thunk/redux_thunk.dart'; 6 | 7 | ThunkAction upvote(Photo photo) { 8 | return (Store store) { 9 | redditRepository 10 | .upvote(photo.id) 11 | .then((_) => store.dispatch(PhotoUpvoted(photo.id))); 12 | }; 13 | } 14 | 15 | ThunkAction cancelUpvote(Photo photo) { 16 | return (Store store) { 17 | redditRepository 18 | .cancelUpvote(photo.id) 19 | .then((_) => store.dispatch(PhotoUpvoteCanceled(photo.id))); 20 | }; 21 | } 22 | 23 | class FetchedPhotos { 24 | final List photos; 25 | 26 | FetchedPhotos(this.photos); 27 | } 28 | 29 | class PhotoUpvoted { 30 | final String id; 31 | 32 | PhotoUpvoted(this.id); 33 | } 34 | 35 | class PhotoUpvoteCanceled { 36 | final String id; 37 | 38 | PhotoUpvoteCanceled(this.id); 39 | } 40 | -------------------------------------------------------------------------------- /lib/models/subreddit.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:built_value/built_value.dart'; 4 | import 'package:built_value/serializer.dart'; 5 | 6 | part 'subreddit.g.dart'; 7 | 8 | abstract class Subreddit implements Built { 9 | String get id; 10 | 11 | String get name; 12 | 13 | bool get nsfw; 14 | 15 | String get primaryColor; 16 | 17 | Color get primaryColorMapped { 18 | return primaryColor.isNotEmpty 19 | ? Color(int.parse('FF' + primaryColor.replaceAll('#', ''), radix: 16)) 20 | : null; 21 | } 22 | 23 | String get iconUrl; 24 | 25 | String get submissionType; 26 | 27 | Subreddit._(); 28 | 29 | factory Subreddit([updates(SubredditBuilder b)]) { 30 | return _$Subreddit 31 | ._( 32 | id: '', 33 | name: '', 34 | nsfw: false, 35 | primaryColor: '', 36 | iconUrl: '', 37 | submissionType: '', 38 | ) 39 | .rebuild(updates); 40 | } 41 | 42 | static Serializer get serializer => _$subredditSerializer; 43 | } 44 | -------------------------------------------------------------------------------- /lib/store/auth/auth_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | 4 | part 'auth_state.g.dart'; 5 | 6 | abstract class AuthState implements Built { 7 | @nullable 8 | String get username; 9 | 10 | AuthStatus get status; 11 | 12 | AuthState._(); 13 | 14 | factory AuthState([updates(AuthStateBuilder b)]) { 15 | return _$AuthState._( 16 | username: null, 17 | status: AuthStatus.unknown, 18 | ).rebuild(updates); 19 | } 20 | } 21 | 22 | class AuthStatus extends EnumClass { 23 | static const AuthStatus unknown = _$unknown; 24 | static const AuthStatus guest = _$guest; 25 | static const AuthStatus authenticating = _$authenticating; 26 | static const AuthStatus authenticated = _$authenticated; 27 | static const AuthStatus signingOut = _$signingOut; 28 | 29 | const AuthStatus._(String name) : super(name); 30 | 31 | static BuiltSet get values => _$values; 32 | 33 | static AuthStatus valueOf(String name) => _$valueOf(name); 34 | } 35 | -------------------------------------------------------------------------------- /lib/store/subscriptions/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:reddigram/store/store.dart'; 3 | import 'package:redux/redux.dart'; 4 | 5 | Reducer> subscriptionsReducer = combineReducers([ 6 | TypedReducer, FetchedSubscriptions>(_fetchedSubscriptions), 7 | TypedReducer, SubscribedSubreddit>(_subscribedSubreddit), 8 | TypedReducer, UnsubscribedSubreddit>(_unsubscribedSubreddit), 9 | ]); 10 | 11 | BuiltSet _fetchedSubscriptions( 12 | BuiltSet state, FetchedSubscriptions action) { 13 | return state.rebuild((b) => b.replace(action.subreddits)); 14 | } 15 | 16 | BuiltSet _subscribedSubreddit( 17 | BuiltSet state, SubscribedSubreddit action) { 18 | return state.rebuild((b) => b.add(action.name)); 19 | } 20 | 21 | BuiltSet _unsubscribedSubreddit( 22 | BuiltSet state, UnsubscribedSubreddit action) { 23 | return state.rebuild((b) => 24 | b.removeWhere((sub) => sub.toLowerCase() == action.name.toLowerCase())); 25 | } 26 | -------------------------------------------------------------------------------- /lib/store/preferences/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/store/store.dart'; 2 | import 'package:redux/redux.dart'; 3 | 4 | Reducer preferencesReducer = combineReducers([ 5 | TypedReducer(_setPreferencesBulk), 6 | TypedReducer(_setTheme), 7 | TypedReducer(_setShowNsfw), 8 | TypedReducer(_setCutLongPhotos), 9 | ]); 10 | 11 | PreferencesState _setPreferencesBulk( 12 | PreferencesState state, SetPreferencesBulk action) { 13 | return action.preferences; 14 | } 15 | 16 | PreferencesState _setTheme(PreferencesState state, SetTheme action) { 17 | return state.rebuild((b) => b..theme = action.theme); 18 | } 19 | 20 | PreferencesState _setShowNsfw(PreferencesState state, SetShowNsfw action) { 21 | return state.rebuild((b) => b..showNsfw = action.showNsfw); 22 | } 23 | 24 | PreferencesState _setCutLongPhotos( 25 | PreferencesState state, SetCutLongPhotos action) { 26 | return state.rebuild((b) => b..cutLongPhotos = action.cutLongPhotos); 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Glance

3 | 4 | Download on Google Play 5 | 6 |
7 | 8 | ## About the app 9 | 10 | ![Screens preview](media/screens_preview.jpg) 11 | 12 | Glance is an unofficial client for Reddit. However, it allows you to only browse through media posts such as photos and videos. It's purpose is mainly to lurk majestic photos subreddits as well as memes, videos and gifs. 13 | 14 | ### Features 15 | 16 | - Subscribing to subs (separately from your Reddit account) 17 | - Importing subscriptions from your Reddit account 18 | - Upvoting (click the arrow or double-tap) 19 | - Downloading photos 20 | - Browsing only certain subreddit 21 | - Dark mode 22 | 23 | ## Related websites 24 | 25 | - [Landing page](https://reddigram.wolszon.me) 26 | - [r/GlanceApp](https://www.reddit.com/r/GlanceApp/) 27 | 28 | ## Related repositories 29 | 30 | - [Glance API repository](https://github.com/Albert221/GlanceApi) 31 | - [Landing page repository](https://github.com/Albert221/GlanceLanding) 32 | 33 | -------------------------------------------------------------------------------- /android/app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "564944230037", 4 | "firebase_url": "https://reddigram-a9b2a.firebaseio.com", 5 | "project_id": "reddigram-a9b2a", 6 | "storage_bucket": "reddigram-a9b2a.appspot.com" 7 | }, 8 | "client": [ 9 | { 10 | "client_info": { 11 | "mobilesdk_app_id": "1:564944230037:android:8ee4e4deed841a5e", 12 | "android_client_info": { 13 | "package_name": "me.wolszon.reddigram" 14 | } 15 | }, 16 | "oauth_client": [ 17 | { 18 | "client_id": "564944230037-lq9kksm95knu219m526a7fk6mte91pgi.apps.googleusercontent.com", 19 | "client_type": 3 20 | } 21 | ], 22 | "api_key": [ 23 | { 24 | "current_key": "AIzaSyAM_tlWshli1nv58I4m-vUYduH3oLrP7DM" 25 | } 26 | ], 27 | "services": { 28 | "appinvite_service": { 29 | "other_platform_oauth_client": [ 30 | { 31 | "client_id": "564944230037-lq9kksm95knu219m526a7fk6mte91pgi.apps.googleusercontent.com", 32 | "client_type": 3 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | ], 39 | "configuration_version": "1" 40 | } -------------------------------------------------------------------------------- /lib/models/photo.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/built_value.dart'; 2 | 3 | part 'photo.g.dart'; 4 | 5 | abstract class Photo implements Built { 6 | String get id; 7 | 8 | String get title; 9 | 10 | String get authorName; 11 | 12 | String get subredditName; 13 | 14 | String get subredditId; 15 | 16 | PhotoMedia get source; 17 | 18 | PhotoMedia get fullImage; 19 | 20 | PhotoMedia get thumbnail; 21 | 22 | @nullable 23 | Video get video; 24 | 25 | bool get isVideo => video != null; 26 | 27 | int get upvotes; 28 | 29 | bool get upvoted; 30 | 31 | bool get nsfw; 32 | 33 | String get redditUrl; 34 | 35 | Photo._(); 36 | 37 | factory Photo([updates(PhotoBuilder b)]) = _$Photo; 38 | } 39 | 40 | abstract class PhotoMedia implements Built { 41 | String get url; 42 | 43 | int get width; 44 | 45 | int get height; 46 | 47 | double get aspectRatio => width / height; 48 | 49 | PhotoMedia._(); 50 | 51 | factory PhotoMedia([updates(PhotoMediaBuilder b)]) = _$PhotoMedia; 52 | } 53 | 54 | abstract class Video implements Built { 55 | String get url; 56 | 57 | Video._(); 58 | 59 | factory Video([updates(VideoBuilder b)]) = _$Video; 60 | } 61 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: reddigram 2 | description: > 3 | Another client for Reddit, but this time, it's not just a client. 4 | It's a different way of browsing photos, GIFs, memes and other things there! 5 | 6 | version: 1.4.2+11 7 | 8 | environment: 9 | sdk: ">=2.2.2 <3.0.0" 10 | 11 | dependencies: 12 | flutter: 13 | sdk: flutter 14 | 15 | built_collection: ^4.2.0 16 | built_value: ^6.4.0 17 | cached_network_image: ^2.0.0-rc 18 | dio: ^2.1.2 19 | firebase_analytics: ^2.1.1+3 20 | firebase_core: ^0.3.4 21 | firebase_crashlytics: ^0.0.4+2 22 | flutter_redux: ^0.5.3 23 | flutter_widgets: ^0.1.6 24 | jaguar_jwt: ^2.1.6 25 | package_info: ^0.4.0+3 26 | photo_view: ^0.4.0 27 | redux: ^3.0.0 28 | redux_thunk: ^0.2.1 29 | shared_preferences: ^0.5.2 30 | uni_links: ^0.2.0 31 | url_launcher: ^5.0.2 32 | validators: ^2.0.0+1 33 | video_player: ^0.10.1+3 34 | 35 | dev_dependencies: 36 | flutter_test: 37 | sdk: flutter 38 | 39 | build_runner: ^1.3.3 40 | built_value_generator: ^6.4.0 41 | 42 | flutter: 43 | uses-material-design: true 44 | 45 | assets: 46 | - assets/chrome_desktop_site.png 47 | 48 | fonts: 49 | - family: Pacifico 50 | fonts: 51 | - asset: assets/fonts/Pacifico/Pacifico-Regular.ttf -------------------------------------------------------------------------------- /lib/store/subreddits/actions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:reddigram/api/api.dart'; 4 | import 'package:reddigram/models/models.dart'; 5 | import 'package:reddigram/store/store.dart'; 6 | import 'package:redux/redux.dart'; 7 | import 'package:redux_thunk/redux_thunk.dart'; 8 | 9 | ThunkAction fetchSubreddits(List ids, 10 | {Completer completer}) { 11 | assert(ids.length <= 100, 'there\'s more than 100 ids. what.'); 12 | 13 | return (Store store) { 14 | // Make a copy so we don't operate on a reference passed to the function 15 | var idsList = List.of(ids); 16 | // Firstly check the state for already fetched subreddits so we don't 17 | // have to call the API for the data we have. 18 | idsList.removeWhere((id) => store.state.subreddits.containsKey(id)); 19 | // Remove duplicates. 20 | idsList = idsList.toSet().toList(); 21 | 22 | redditRepository 23 | .subredditsBulk(idsList) 24 | .then((subreddits) => store.dispatch(FetchedSubreddits(subreddits))) 25 | .whenComplete(() => completer?.complete()); 26 | }; 27 | } 28 | 29 | /// [FetchedSubreddits] is called whenever we fetch the subreddit data. 30 | /// It's used to show subreddits data to the user in various locations 31 | /// across the application. 32 | class FetchedSubreddits { 33 | final List subreddits; 34 | 35 | FetchedSubreddits(this.subreddits); 36 | } 37 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/me/wolszon/reddigram/ReddigramApplication.kt: -------------------------------------------------------------------------------- 1 | package me.wolszon.reddigram 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.content.Context 6 | import android.os.Build 7 | import io.flutter.app.FlutterApplication 8 | import java.util.concurrent.atomic.AtomicInteger 9 | 10 | class ReddigramApplication : FlutterApplication() { 11 | companion object { 12 | const val DOWNLOAD_CHANNEL_ID = "DOWNLOAD_CHANNEL" 13 | 14 | private val notificationIdAtomic = AtomicInteger(1) 15 | val newNotificationId 16 | get() = notificationIdAtomic.getAndIncrement() 17 | } 18 | 19 | override fun onCreate() { 20 | super.onCreate() 21 | 22 | createNotificationChannel() 23 | } 24 | 25 | private fun createNotificationChannel() { 26 | // Create the NotificationChannel, but only on API 26+ 27 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 28 | val channel = NotificationChannel( 29 | DOWNLOAD_CHANNEL_ID, 30 | "Downloading photos", 31 | NotificationManager.IMPORTANCE_DEFAULT) 32 | 33 | val notificationManager: NotificationManager = 34 | getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 35 | notificationManager.createNotificationChannel(channel) 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /lib/store/photos/reducer.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:reddigram/models/models.dart'; 3 | import 'package:reddigram/store/photos/actions.dart'; 4 | import 'package:redux/redux.dart'; 5 | 6 | List photosIds(Iterable photos) => 7 | photos.map((photo) => photo.id).toList(); 8 | 9 | Reducer> photosReducer = combineReducers([ 10 | TypedReducer, FetchedPhotos>(_fetchedPhotos), 11 | TypedReducer, PhotoUpvoted>(_photoUpvoted), 12 | TypedReducer, PhotoUpvoteCanceled>( 13 | _photoUpvoteCanceled), 14 | ]); 15 | 16 | BuiltMap _fetchedPhotos( 17 | BuiltMap state, FetchedPhotos action) { 18 | return state 19 | .rebuild((b) => b.addIterable(action.photos, key: (photo) => photo.id)); 20 | } 21 | 22 | BuiltMap _photoUpvoted( 23 | BuiltMap state, PhotoUpvoted action) { 24 | return state.rebuild((b) => b.updateValue( 25 | action.id, 26 | (photo) => photo.rebuild((b) => b 27 | ..upvoted = true 28 | ..upvotes = b.upvotes + 1))); 29 | } 30 | 31 | BuiltMap _photoUpvoteCanceled( 32 | BuiltMap state, PhotoUpvoteCanceled action) { 33 | return state.rebuild((b) => b.updateValue( 34 | action.id, 35 | (photo) => photo.rebuild((b) => b 36 | ..upvoted = false 37 | ..upvotes = b.upvotes - 1))); 38 | } 39 | -------------------------------------------------------------------------------- /lib/widgets/subreddit_list_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:reddigram/models/models.dart'; 4 | import 'package:reddigram/widgets/subreddit_circle_avatar.dart'; 5 | 6 | class SubredditListTile extends StatelessWidget { 7 | final Subreddit subreddit; 8 | final VoidCallback onTap; 9 | final bool subscribed; 10 | final VoidCallback onSubscribe; 11 | final VoidCallback onUnsubscribe; 12 | 13 | const SubredditListTile({ 14 | Key key, 15 | @required this.subreddit, 16 | @required this.subscribed, 17 | this.onTap, 18 | this.onSubscribe, 19 | this.onUnsubscribe, 20 | }) : assert(subreddit != null), 21 | super(key: key); 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return ListTile( 26 | leading: SubredditCircleAvatar(subreddit: subreddit), 27 | title: Text('r/${subreddit.name}'), 28 | contentPadding: const EdgeInsets.only(left: 16), 29 | onTap: onTap, 30 | trailing: subscribed 31 | ? InkWell( 32 | onTap: onUnsubscribe, 33 | child: Padding( 34 | padding: const EdgeInsets.all(16), 35 | child: Icon(Icons.remove), 36 | ), 37 | ) 38 | : InkWell( 39 | onTap: onSubscribe, 40 | child: Padding( 41 | padding: const EdgeInsets.all(16), 42 | child: Icon(Icons.add), 43 | ), 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/api/api_repository/facade.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | import 'package:reddigram/api/api.dart'; 3 | 4 | enum SubscriptionsBackendType { Api, Local } 5 | 6 | class ApiRepositoriesFacade extends ApiApiRepository { 7 | final ApiLocalRepository localRepository = ApiLocalRepository(); 8 | 9 | SubscriptionsBackendType _backendType = SubscriptionsBackendType.Local; 10 | 11 | ApiRepositoriesFacade({@required fetchRedditAccessToken}) 12 | : assert(fetchRedditAccessToken != null), 13 | super(fetchRedditAccessToken: fetchRedditAccessToken); 14 | 15 | Future useApi(String redditAccessToken) { 16 | return apiRepository 17 | .authenticate(redditAccessToken) 18 | .then((_) => _backendType = SubscriptionsBackendType.Api); 19 | } 20 | 21 | void useLocal() => _backendType = SubscriptionsBackendType.Local; 22 | 23 | @override 24 | Future> fetchSubscriptions( 25 | {SubscriptionsBackendType forceBackend}) => 26 | (forceBackend ?? _backendType) == SubscriptionsBackendType.Api 27 | ? super.fetchSubscriptions() 28 | : localRepository.fetchSubscriptions(); 29 | 30 | @override 31 | Future subscribeSubreddit(String name) => 32 | _backendType == SubscriptionsBackendType.Api 33 | ? super.subscribeSubreddit(name) 34 | : localRepository.subscribeSubreddit(name); 35 | 36 | @override 37 | Future unsubscribeSubreddit(String name) => 38 | _backendType == SubscriptionsBackendType.Api 39 | ? super.unsubscribeSubreddit(name) 40 | : localRepository.unsubscribeSubreddit(name); 41 | } 42 | -------------------------------------------------------------------------------- /lib/widgets/nsfw_overlay.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class NsfwOverlay extends StatelessWidget { 6 | final bool show; 7 | final VoidCallback onShow; 8 | 9 | const NsfwOverlay({Key key, this.show = false, this.onShow}) 10 | : super(key: key); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return IgnorePointer( 15 | ignoring: show, 16 | child: GestureDetector( 17 | onTap: onShow, 18 | child: AnimatedOpacity( 19 | opacity: show ? 0 : 1, 20 | duration: const Duration(milliseconds: 500), 21 | curve: Curves.ease, 22 | child: BackdropFilter( 23 | filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), 24 | child: Container( 25 | color: Colors.black.withOpacity(.3), 26 | alignment: Alignment.center, 27 | child: Column( 28 | mainAxisSize: MainAxisSize.min, 29 | children: [ 30 | Text( 31 | 'NSFW', 32 | style: Theme.of(context) 33 | .textTheme 34 | .title 35 | .copyWith(color: Colors.white), 36 | ), 37 | const SizedBox(height: 12.0), 38 | const Icon( 39 | Icons.block, 40 | size: 48.0, 41 | color: Colors.white, 42 | ), 43 | ], 44 | ), 45 | ), 46 | ), 47 | ), 48 | ), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | Glance 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # Visual Studio Code related 19 | .vscode/ 20 | 21 | # Flutter/Dart/Pub related 22 | **/doc/api/ 23 | .dart_tool/ 24 | .flutter-plugins 25 | .flutter-plugins-dependencies 26 | .packages 27 | .pub-cache/ 28 | .pub/ 29 | /build/ 30 | 31 | # Android related 32 | **/android/**/gradle-wrapper.jar 33 | **/android/.gradle 34 | **/android/captures/ 35 | **/android/gradlew 36 | **/android/gradlew.bat 37 | **/android/local.properties 38 | **/android/key.properties 39 | **/android/**/GeneratedPluginRegistrant.java 40 | 41 | # iOS/XCode related 42 | **/ios/**/*.mode1v3 43 | **/ios/**/*.mode2v3 44 | **/ios/**/*.moved-aside 45 | **/ios/**/*.pbxuser 46 | **/ios/**/*.perspectivev3 47 | **/ios/**/*sync/ 48 | **/ios/**/.sconsign.dblite 49 | **/ios/**/.tags* 50 | **/ios/**/.vagrant/ 51 | **/ios/**/DerivedData/ 52 | **/ios/**/Icon? 53 | **/ios/**/Pods/ 54 | **/ios/**/.symlinks/ 55 | **/ios/**/profile 56 | **/ios/**/xcuserdata 57 | **/ios/.generated/ 58 | **/ios/Flutter/App.framework 59 | **/ios/Flutter/Flutter.framework 60 | **/ios/Flutter/Generated.xcconfig 61 | **/ios/Flutter/app.flx 62 | **/ios/Flutter/app.zip 63 | **/ios/Flutter/flutter_assets/ 64 | **/ios/Flutter/flutter_export_environment.sh 65 | **/ios/ServiceDefinitions.json 66 | **/ios/Runner/GeneratedPluginRegistrant.* 67 | 68 | # Exceptions to above rules. 69 | !**/ios/**/default.mode1v3 70 | !**/ios/**/default.mode2v3 71 | !**/ios/**/default.pbxuser 72 | !**/ios/**/default.perspectivev3 73 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 74 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 21 | 22 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /lib/screens/photo_preview.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/services.dart'; 4 | import 'package:photo_view/photo_view.dart'; 5 | import 'package:reddigram/models/models.dart'; 6 | 7 | class PhotoPreviewScreen extends StatelessWidget { 8 | static const _methodChannel = const MethodChannel('me.wolszon.reddigram'); 9 | 10 | static PageRoute route(Photo photo) { 11 | return MaterialPageRoute( 12 | settings: const RouteSettings(name: 'PhotoPreviewScreen'), 13 | builder: (context) => PhotoPreviewScreen(photo: photo), 14 | ); 15 | } 16 | 17 | final Photo photo; 18 | 19 | const PhotoPreviewScreen({Key key, @required this.photo}) 20 | : assert(photo != null), 21 | super(key: key); 22 | 23 | void _downloadPhoto() { 24 | _methodChannel.invokeMethod('downloadPhoto', {'url': photo.source.url}); 25 | } 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | final minScale = MediaQuery.of(context).size.width / photo.source.width; 30 | 31 | return Scaffold( 32 | extendBodyBehindAppBar: true, 33 | appBar: AppBar( 34 | backgroundColor: Theme.of(context).appBarTheme.color.withOpacity(0.37), 35 | actions: [ 36 | IconButton( 37 | icon: Icon(Icons.file_download), 38 | tooltip: 'Download', 39 | onPressed: _downloadPhoto, 40 | ), 41 | ], 42 | ), 43 | body: PhotoView( 44 | transitionOnUserGestures: true, 45 | backgroundDecoration: BoxDecoration( 46 | color: Theme.of(context).scaffoldBackgroundColor, 47 | ), 48 | imageProvider: CachedNetworkImageProvider(photo.source.url), 49 | minScale: minScale, 50 | maxScale: 2.0, 51 | ), 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class ReddigramTheme { 4 | static ThemeData light() { 5 | final lightTheme = ThemeData.light(); 6 | 7 | return lightTheme.copyWith( 8 | primaryColor: Colors.black, 9 | accentColor: Colors.black, 10 | scaffoldBackgroundColor: Colors.white, 11 | appBarTheme: AppBarTheme( 12 | elevation: 0, 13 | color: Colors.white, 14 | textTheme: TextTheme( 15 | title: TextStyle( 16 | color: Colors.black, 17 | fontSize: 20.0, 18 | fontWeight: FontWeight.bold, 19 | ), 20 | ), 21 | iconTheme: IconThemeData( 22 | color: Colors.black, 23 | ), 24 | ), 25 | tabBarTheme: TabBarTheme( 26 | labelColor: Colors.black, 27 | ), 28 | textTheme: lightTheme.textTheme.copyWith( 29 | caption: TextStyle( 30 | color: Colors.black, 31 | fontSize: 16.0, 32 | fontWeight: FontWeight.bold, 33 | ), 34 | body1: TextStyle( 35 | color: Colors.black, 36 | fontSize: 15.0, 37 | ), 38 | ), 39 | ); 40 | } 41 | 42 | static ThemeData dark() { 43 | final lightTheme = ThemeData.dark(); 44 | 45 | return lightTheme.copyWith( 46 | accentColor: Colors.white, 47 | scaffoldBackgroundColor: Colors.black, 48 | appBarTheme: AppBarTheme( 49 | elevation: 0, 50 | color: Colors.black, 51 | ), 52 | textTheme: lightTheme.textTheme.copyWith( 53 | caption: TextStyle( 54 | color: Colors.white, 55 | fontSize: 16.0, 56 | fontWeight: FontWeight.bold, 57 | ), 58 | body1: TextStyle( 59 | color: Colors.white, 60 | fontSize: 15.0, 61 | ), 62 | ), 63 | cardColor: Colors.black, 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/api/response_models/serializers.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'serializers.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | Serializers _$serializers = (new Serializers().toBuilder() 10 | ..add(Image.serializer) 11 | ..add(LinkChild.serializer) 12 | ..add(LinkChildData.serializer) 13 | ..add(LinkListingData.serializer) 14 | ..add(LinkListingResponse.serializer) 15 | ..add(Media.serializer) 16 | ..add(Preview.serializer) 17 | ..add(PreviewImage.serializer) 18 | ..add(RedditVideo.serializer) 19 | ..add(Subreddit.serializer) 20 | ..add(SubredditListData.serializer) 21 | ..add(SubredditListResponse.serializer) 22 | ..add(SubredditResponse.serializer) 23 | ..addBuilderFactory( 24 | const FullType(BuiltList, const [const FullType(Image)]), 25 | () => new ListBuilder()) 26 | ..addBuilderFactory( 27 | const FullType(BuiltList, const [const FullType(LinkChild)]), 28 | () => new ListBuilder()) 29 | ..addBuilderFactory( 30 | const FullType(BuiltList, const [const FullType(PreviewImage)]), 31 | () => new ListBuilder()) 32 | ..addBuilderFactory( 33 | const FullType(BuiltList, const [const FullType(SubredditResponse)]), 34 | () => new ListBuilder())) 35 | .build(); 36 | 37 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 38 | -------------------------------------------------------------------------------- /lib/store/subscriptions/actions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:reddigram/api/api.dart'; 4 | import 'package:reddigram/store/store.dart'; 5 | import 'package:redux/redux.dart'; 6 | import 'package:redux_thunk/redux_thunk.dart'; 7 | 8 | ThunkAction fetchSubscriptions([Completer completer]) { 9 | return (Store store) { 10 | apiRepository.fetchSubscriptions().then((subreddits) async { 11 | final subredditsCompleter = Completer(); 12 | store.dispatch( 13 | fetchSubreddits(subreddits, completer: subredditsCompleter)); 14 | await subredditsCompleter.future; 15 | 16 | store.dispatch(FetchedSubscriptions(subreddits)); 17 | 18 | store.dispatch(fetchSuggestedSubscriptions()); 19 | }).whenComplete(() => completer?.complete()); 20 | }; 21 | } 22 | 23 | ThunkAction subscribeSubreddit(String id) { 24 | return (Store store) { 25 | apiRepository.subscribeSubreddit(id).then((_) { 26 | store.dispatch(SubscribedSubreddit(id)); 27 | store.dispatch(fetchFreshFeed(NEW_SUBSCRIBED)); 28 | store.dispatch(fetchFreshFeed(BEST_SUBSCRIBED)); 29 | }); 30 | }; 31 | } 32 | 33 | ThunkAction unsubscribeSubreddit(String id) { 34 | return (Store store) { 35 | apiRepository.unsubscribeSubreddit(id).then((_) { 36 | store.dispatch(UnsubscribedSubreddit(id)); 37 | store.dispatch(fetchFreshFeed(NEW_SUBSCRIBED)); 38 | store.dispatch(fetchFreshFeed(BEST_SUBSCRIBED)); 39 | }); 40 | }; 41 | } 42 | 43 | class FetchedSubscriptions { 44 | final List subreddits; 45 | 46 | FetchedSubscriptions(this.subreddits); 47 | } 48 | 49 | class SubscribedSubreddit { 50 | final String name; 51 | 52 | SubscribedSubreddit(this.name); 53 | } 54 | 55 | class UnsubscribedSubreddit { 56 | final String name; 57 | 58 | UnsubscribedSubreddit(this.name); 59 | } 60 | -------------------------------------------------------------------------------- /lib/store/app_state.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | import 'package:reddigram/models/models.dart'; 4 | import 'package:reddigram/store/store.dart'; 5 | 6 | part 'app_state.g.dart'; 7 | 8 | abstract class ReddigramState 9 | implements Built { 10 | AuthState get authState; 11 | 12 | PreferencesState get preferences; 13 | 14 | /// Map of all photos in application; key is an id of a photo. 15 | BuiltMap get photos; 16 | 17 | /// Map with all feeds in application. There are three reserved values: 18 | /// [POPULAR], [NEW_SUBSCRIBED], and [BEST_SUBSCRIBED], the rest of values 19 | /// are subreddits' names with correct capitalization, without "r/" prefix. 20 | BuiltMap get feeds; 21 | 22 | /// Map of all subreddits in application (not only those which feed was 23 | /// loaded, but also all shown in badges). Key is a subreddit id. 24 | BuiltMap get subreddits; 25 | 26 | /// Ids of subscribed subreddits. 27 | BuiltSet get subscriptions; 28 | 29 | /// Ids of suggested to subscribe subreddits. 30 | BuiltSet get suggestedSubscriptions; 31 | 32 | SubredditsSearchState get subredditsSearch; 33 | 34 | ReddigramState._(); 35 | 36 | factory ReddigramState([updates(ReddigramStateBuilder b)]) { 37 | return _$ReddigramState 38 | ._( 39 | authState: AuthState(), 40 | preferences: PreferencesState(), 41 | photos: BuiltMap(), 42 | feeds: BuiltMap({ 43 | POPULAR: Feed(), 44 | NEW_SUBSCRIBED: Feed(), 45 | BEST_SUBSCRIBED: Feed(), 46 | }), 47 | subreddits: BuiltMap(), 48 | subscriptions: BuiltSet(), 49 | suggestedSubscriptions: BuiltSet(), 50 | subredditsSearch: SubredditsSearchState(), 51 | ) 52 | .rebuild(updates); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/store/preferences/actions.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/store/store.dart'; 2 | import 'package:redux/redux.dart'; 3 | import 'package:redux_thunk/redux_thunk.dart'; 4 | import 'package:shared_preferences/shared_preferences.dart'; 5 | 6 | ThunkAction loadPreferences() { 7 | return (Store store) async { 8 | final prefs = await SharedPreferences.getInstance(); 9 | 10 | store.dispatch(SetPreferencesBulk(PreferencesState((b) => b 11 | ..theme = 12 | prefs.getString('theme') == 'dark' ? AppTheme.dark : AppTheme.light 13 | ..showNsfw = prefs.getBool('show_nsfw') ?? false 14 | ..cutLongPhotos = prefs.getBool('cut_long_photos') ?? false))); 15 | }; 16 | } 17 | 18 | ThunkAction setTheme(AppTheme theme) { 19 | return (Store store) async { 20 | (await SharedPreferences.getInstance()) 21 | .setString('theme', theme.toString()); 22 | 23 | store.dispatch(SetTheme(theme)); 24 | }; 25 | } 26 | 27 | ThunkAction setShowNsfw(bool showNsfw) { 28 | return (Store store) async { 29 | (await SharedPreferences.getInstance()).setBool('show_nsfw', showNsfw); 30 | 31 | store.dispatch(SetShowNsfw(showNsfw)); 32 | }; 33 | } 34 | 35 | ThunkAction setCutLongPhotos(bool cutLongPhotos) { 36 | return (Store store) async { 37 | (await SharedPreferences.getInstance()) 38 | .setBool('cut_long_photos', cutLongPhotos); 39 | 40 | store.dispatch(SetCutLongPhotos(cutLongPhotos)); 41 | }; 42 | } 43 | 44 | class SetPreferencesBulk { 45 | final PreferencesState preferences; 46 | 47 | SetPreferencesBulk(this.preferences); 48 | } 49 | 50 | class SetTheme { 51 | final AppTheme theme; 52 | 53 | SetTheme(this.theme); 54 | } 55 | 56 | class SetShowNsfw { 57 | final bool showNsfw; 58 | 59 | SetShowNsfw(this.showNsfw); 60 | } 61 | 62 | class SetCutLongPhotos { 63 | final bool cutLongPhotos; 64 | 65 | SetCutLongPhotos(this.cutLongPhotos); 66 | } 67 | -------------------------------------------------------------------------------- /lib/widgets/infinite_list.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | class InfiniteList extends StatefulWidget { 6 | final int itemCount; 7 | final Widget Function(BuildContext, int) itemBuilder; 8 | final void Function(Completer) fetchMore; 9 | final bool keepAlive; 10 | 11 | const InfiniteList( 12 | {Key key, 13 | this.itemCount, 14 | this.itemBuilder, 15 | this.fetchMore, 16 | this.keepAlive = false}) 17 | : super(key: key); 18 | 19 | @override 20 | InfiniteListState createState() => InfiniteListState(); 21 | } 22 | 23 | class InfiniteListState extends State 24 | with AutomaticKeepAliveClientMixin { 25 | final _scrollController = ScrollController(); 26 | 27 | double Function() _offsetToLoad = () => 0; 28 | Completer completer = Completer()..complete(); 29 | 30 | void scrollToOffset(double offset, 31 | {Duration duration = const Duration(milliseconds: 300)}) { 32 | _scrollController.animateTo(offset, duration: duration, curve: Curves.ease); 33 | } 34 | 35 | @override 36 | void initState() { 37 | super.initState(); 38 | 39 | _scrollController.addListener(() { 40 | final position = _scrollController.position; 41 | if (position.pixels + _offsetToLoad() >= position.maxScrollExtent && 42 | completer.isCompleted) { 43 | completer = Completer(); 44 | widget.fetchMore(completer); 45 | } 46 | }); 47 | } 48 | 49 | @override 50 | void dispose() { 51 | _scrollController.dispose(); 52 | super.dispose(); 53 | } 54 | 55 | @override 56 | Widget build(BuildContext context) { 57 | super.build(context); 58 | _offsetToLoad = () => MediaQuery.of(context).size.height * 3; 59 | 60 | return ListView.builder( 61 | physics: const AlwaysScrollableScrollPhysics(), 62 | controller: _scrollController, 63 | itemCount: widget.itemCount, 64 | itemBuilder: widget.itemBuilder, 65 | ); 66 | } 67 | 68 | @override 69 | bool get wantKeepAlive => widget.keepAlive; 70 | } 71 | -------------------------------------------------------------------------------- /lib/store/subreddits_search/actions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:reddigram/api/api.dart'; 5 | import 'package:reddigram/store/store.dart'; 6 | import 'package:redux/redux.dart'; 7 | import 'package:redux_thunk/redux_thunk.dart'; 8 | 9 | ThunkAction searchSubreddits(String query, 10 | {Completer completer}) { 11 | return (Store store) { 12 | redditRepository.searchSubreddits(query).then((subreddits) async { 13 | // If there was no subreddit with this particular name... 14 | if (!subreddits.any( 15 | (subreddit) => subreddit.name.toLowerCase() == query.toLowerCase())) { 16 | // ...but this subreddit exists... 17 | redditRepository.subreddit(query).then((subreddit) { 18 | store.dispatch(FetchedSubreddits([subreddit])); 19 | // ...then prepend it to fetched search subreddits. 20 | store.dispatch(FetchedSearchSubreddits( 21 | query, 22 | [subreddit.id, ...subreddits.map((subreddit) => subreddit.id)], 23 | )); 24 | }).whenComplete(() => completer?.complete()); 25 | } else { 26 | completer?.complete(); 27 | } 28 | 29 | store.dispatch(FetchedSubreddits(subreddits)); 30 | store.dispatch(FetchedSearchSubreddits( 31 | query, subreddits.map((subreddit) => subreddit.id).toList())); 32 | }, onError: (error) { 33 | // Sometimes (well, not that rarely) Reddit's search service 34 | // isn't working, fetch the exact subreddit only then. 35 | if (error is DioError && error.response.statusCode == 503) { 36 | redditRepository.subreddit(query).then((subreddit) { 37 | store.dispatch(FetchedSubreddits([subreddit])); 38 | store.dispatch(FetchedSearchSubreddits(query, [subreddit.id])); 39 | }).whenComplete(() => completer?.complete()); 40 | } 41 | }); 42 | }; 43 | } 44 | 45 | class ClearSearch {} 46 | 47 | class FetchedSearchSubreddits { 48 | final String query; 49 | final List resultSubredditsIds; 50 | 51 | FetchedSearchSubreddits(this.query, this.resultSubredditsIds); 52 | } 53 | -------------------------------------------------------------------------------- /lib/api/response_models/subreddit_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_collection/built_collection.dart'; 2 | import 'package:built_value/built_value.dart'; 3 | import 'package:built_value/serializer.dart'; 4 | 5 | part 'subreddit_response.g.dart'; 6 | 7 | abstract class SubredditListResponse 8 | implements Built { 9 | SubredditListData get data; 10 | 11 | SubredditListResponse._(); 12 | 13 | factory SubredditListResponse([updates(SubredditListResponseBuilder b)]) = 14 | _$SubredditListResponse; 15 | 16 | static Serializer get serializer => 17 | _$subredditListResponseSerializer; 18 | } 19 | 20 | abstract class SubredditListData 21 | implements Built { 22 | BuiltList get children; 23 | 24 | SubredditListData._(); 25 | 26 | factory SubredditListData([updates(SubredditListDataBuilder b)]) = 27 | _$SubredditListData; 28 | 29 | static Serializer get serializer => 30 | _$subredditListDataSerializer; 31 | } 32 | 33 | abstract class SubredditResponse 34 | implements Built { 35 | Subreddit get data; 36 | 37 | SubredditResponse._(); 38 | 39 | factory SubredditResponse([updates(SubredditResponseBuilder b)]) = 40 | _$SubredditResponse; 41 | 42 | static Serializer get serializer => 43 | _$subredditResponseSerializer; 44 | } 45 | 46 | abstract class Subreddit implements Built { 47 | String get name; 48 | 49 | @BuiltValueField(wireName: 'display_name') 50 | String get displayName; 51 | 52 | @BuiltValueField(wireName: 'over18') 53 | @nullable 54 | bool get nsfw; 55 | 56 | @BuiltValueField(wireName: 'primary_color') 57 | @nullable 58 | String get primaryColor; 59 | 60 | @BuiltValueField(wireName: 'icon_img') 61 | @nullable 62 | String get iconUrl; 63 | 64 | @BuiltValueField(wireName: 'subreddit_type') 65 | String get subredditType; 66 | 67 | @BuiltValueField(wireName: 'submission_type') 68 | @nullable 69 | String get submissionType; 70 | 71 | Subreddit._(); 72 | 73 | factory Subreddit([updates(SubredditBuilder b)]) = _$Subreddit; 74 | 75 | static Serializer get serializer => _$subredditSerializer; 76 | } 77 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /lib/api/api_repository/api.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:dio/dio.dart'; 5 | import 'package:meta/meta.dart'; 6 | import 'package:reddigram/utils/jwt.dart'; 7 | 8 | class ApiApiRepository { 9 | final String Function() fetchRedditAccessToken; 10 | 11 | Dio _client; 12 | String _token; 13 | 14 | DateTime get _tokenExpiration => jwtExp(_token); 15 | 16 | ApiApiRepository({@required this.fetchRedditAccessToken}) 17 | : assert(fetchRedditAccessToken != null) { 18 | _client = Dio(BaseOptions( 19 | baseUrl: 'https://reddigram-api.herokuapp.com', 20 | )); 21 | 22 | _client.interceptors.addAll([ 23 | InterceptorsWrapper(onRequest: (options) async { 24 | if (_token == null || options.path.contains('authenticate')) { 25 | return options; 26 | } 27 | 28 | final minuteAgo = DateTime.now().subtract(Duration(minutes: 1)); 29 | if (_tokenExpiration.isBefore(minuteAgo)) { 30 | await _refreshToken(); 31 | } 32 | 33 | return options; 34 | }), 35 | InterceptorsWrapper(onRequest: (options) { 36 | // Refreshment of token 37 | if (_token != null) { 38 | options.headers['Authorization'] = 'Bearer $_token'; 39 | } 40 | 41 | return options; 42 | }), 43 | ]); 44 | } 45 | 46 | Future> suggestedSubreddits( 47 | List subscribedSubreddits) async { 48 | return _client 49 | .post('/suggested-subreddits', data: subscribedSubreddits) 50 | .then((response) => List.from(response.data)); 51 | } 52 | 53 | Future authenticate(String redditAccessToken) async { 54 | return _client 55 | .post('/authenticate', 56 | data: 'access_token=$redditAccessToken', 57 | options: Options( 58 | contentType: 59 | ContentType.parse('application/x-www-form-urlencoded'))) 60 | .then((response) => _token = response.data); 61 | } 62 | 63 | Future _refreshToken() async { 64 | return authenticate(fetchRedditAccessToken()); 65 | } 66 | 67 | void clearToken() { 68 | _token = null; 69 | } 70 | 71 | Future> fetchSubscriptions() async { 72 | return _client 73 | .get('/subscriptions') 74 | .then((response) => List.from(response.data)); 75 | } 76 | 77 | Future subscribeSubreddit(String id) async { 78 | return _client.put('/subscriptions/$id'); 79 | } 80 | 81 | Future unsubscribeSubreddit(String id) async { 82 | return _client.delete('/subscriptions/$id'); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/widgets/icon_navigation_bar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class IconNavigationBarItem { 4 | final Widget icon; 5 | final String tooltip; 6 | 7 | const IconNavigationBarItem({@required this.icon, this.tooltip}) 8 | : assert(icon != null); 9 | } 10 | 11 | class IconNavigationBar extends StatefulWidget { 12 | final List icons; 13 | final ValueChanged onTap; 14 | final int currentIndex; 15 | 16 | final Duration animationDuration; 17 | 18 | const IconNavigationBar({ 19 | Key key, 20 | @required this.icons, 21 | this.onTap, 22 | this.currentIndex = 0, 23 | this.animationDuration = const Duration(milliseconds: 300), 24 | }) : assert(icons != null), 25 | assert(icons.length >= 2), 26 | super(key: key); 27 | 28 | @override 29 | _IconNavigationBarState createState() => _IconNavigationBarState(); 30 | } 31 | 32 | class _IconNavigationBarState extends State { 33 | @override 34 | Widget build(BuildContext context) { 35 | final backgroundColor = Theme.of(context).scaffoldBackgroundColor; 36 | final iconsColor = Theme.of(context).iconTheme.color; 37 | 38 | return Container( 39 | color: backgroundColor, 40 | child: Row( 41 | children: widget.icons.map((item) { 42 | final index = widget.icons.indexOf(item); 43 | 44 | final itemWidget = AnimatedContainer( 45 | duration: widget.animationDuration, 46 | width: 44.0, 47 | height: 44.0, 48 | decoration: BoxDecoration( 49 | color: 50 | index == widget.currentIndex ? iconsColor : backgroundColor, 51 | shape: BoxShape.circle, 52 | ), 53 | child: AnimatedTheme( 54 | data: ThemeData( 55 | iconTheme: IconThemeData( 56 | color: index == widget.currentIndex 57 | ? backgroundColor 58 | : iconsColor, 59 | ), 60 | ), 61 | child: item.icon, 62 | ), 63 | ); 64 | 65 | return Expanded( 66 | child: GestureDetector( 67 | behavior: HitTestBehavior.opaque, 68 | onTap: () => widget.onTap(index), 69 | child: Container( 70 | height: 64.0, 71 | alignment: Alignment.center, 72 | child: item.tooltip != null 73 | ? Tooltip( 74 | message: item.tooltip, 75 | child: itemWidget, 76 | ) 77 | : itemWidget, 78 | ), 79 | ), 80 | ); 81 | }).toList(), 82 | ), 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/api/mappers/link_listing_photos_mapper.dart: -------------------------------------------------------------------------------- 1 | import 'package:reddigram/api/response_models/response_models.dart'; 2 | import 'package:reddigram/models/models.dart'; 3 | import 'package:validators/validators.dart'; 4 | 5 | class LinkListingPhotosMapper { 6 | static List map(LinkListingResponse response) { 7 | return response.data.children 8 | .map((child) { 9 | if (['self', 'spoiler'].contains(child.data.thumbnail) || 10 | !isURL(child.data.thumbnail) && child.data.thumbnail != 'nsfw') { 11 | return null; 12 | } 13 | 14 | RedditVideo redditVideo; 15 | if (child.data.isVideo) { 16 | redditVideo = child.data.media?.redditVideo; 17 | } else { 18 | redditVideo = child.data.preview?.redditVideoPreview; 19 | } 20 | 21 | try { 22 | return Photo((b) => b 23 | ..id = child.data.name 24 | ..title = child.data.title 25 | ..authorName = child.data.author 26 | ..subredditName = child.data.subreddit 27 | ..subredditId = child.data.subredditId 28 | ..source = PhotoMedia((b) => b 29 | ..url = child.data.preview.images.first.source.url 30 | .replaceAll('&', '&') 31 | ..width = child.data.preview.images.first.source.width 32 | ..height = child.data.preview.images.first.source.height) 33 | .toBuilder() 34 | ..fullImage = _fullImage(child) 35 | ..thumbnail = child.data.thumbnail != 'nsfw' 36 | ? PhotoMedia((b) => b 37 | ..url = child.data.thumbnail 38 | ..width = child.data.thumbnailWidth ?? 1 39 | ..height = child.data.thumbnailHeight ?? 1).toBuilder() 40 | : _fullImage(child) 41 | ..video = redditVideo != null 42 | ? Video((b) => b..url = redditVideo.fallbackUrl).toBuilder() 43 | : null 44 | ..upvotes = child.data.score 45 | ..upvoted = child.data.likes ?? false 46 | ..nsfw = child.data.over18 47 | ..redditUrl = 'https://reddit.com${child.data.permalink}'); 48 | } catch (e) { 49 | // Catch deserialization errors 50 | return null; 51 | } 52 | }) 53 | .where((photo) => photo != null) 54 | .toList(); 55 | } 56 | 57 | static PhotoMediaBuilder _fullImage(LinkChild child) { 58 | return PhotoMedia((b) => b 59 | ..url = child.data.preview.images.first.resolutions.last.url 60 | .replaceAll('&', '&') 61 | ..width = child.data.preview.images.first.resolutions.last.width 62 | ..height = child.data.preview.images.first.resolutions.last.height) 63 | .toBuilder(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/widgets/upvoteable.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class Upvoteable extends StatefulWidget { 4 | final VoidCallback onUpvote; 5 | 6 | const Upvoteable({Key key, this.onUpvote}) : super(key: key); 7 | 8 | @override 9 | _UpvoteableState createState() => _UpvoteableState(); 10 | } 11 | 12 | class _UpvoteableState extends State 13 | with SingleTickerProviderStateMixin { 14 | AnimationController _controller; 15 | Animation _opacityFirst; 16 | Animation _opacitySecond; 17 | Animation _alignmentFirst; 18 | Animation _alignmentSecond; 19 | 20 | @override 21 | void initState() { 22 | super.initState(); 23 | 24 | _controller = AnimationController( 25 | vsync: this, duration: Duration(milliseconds: 1500)); 26 | 27 | _opacityFirst = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation( 28 | parent: _controller, 29 | curve: const Interval(0, 1 / 4, curve: Curves.ease), 30 | )) 31 | ..addListener(() => setState(() {})); 32 | 33 | _opacitySecond = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation( 34 | parent: _controller, 35 | curve: const Interval(3 / 4, 1, curve: Curves.ease), 36 | )); 37 | 38 | _alignmentFirst = 39 | Tween(begin: Alignment.bottomCenter, end: Alignment.center) 40 | .animate(CurvedAnimation( 41 | parent: _controller, 42 | curve: const Interval(0, 1 / 3, curve: Curves.ease), 43 | )); 44 | 45 | _alignmentSecond = 46 | Tween(begin: Alignment.center, end: Alignment.bottomCenter).animate( 47 | CurvedAnimation( 48 | parent: _controller, 49 | curve: const Interval(2 / 3, 1, curve: Curves.ease))); 50 | } 51 | 52 | @override 53 | void dispose() { 54 | _controller.dispose(); 55 | super.dispose(); 56 | } 57 | 58 | void onUpvote() { 59 | if (widget.onUpvote == null) { 60 | return; 61 | } 62 | 63 | widget.onUpvote(); 64 | _controller.reset(); 65 | _controller.forward(); 66 | } 67 | 68 | @override 69 | Widget build(BuildContext context) { 70 | final opacity = _opacityFirst.value - _opacitySecond.value; 71 | 72 | return SizedBox.expand( 73 | child: GestureDetector( 74 | onDoubleTap: onUpvote, 75 | behavior: HitTestBehavior.translucent, 76 | child: opacity > 0 77 | ? Opacity( 78 | opacity: opacity, 79 | child: Container( 80 | color: Colors.black45, 81 | alignment: _alignmentFirst.value - _alignmentSecond.value, 82 | child: const Icon( 83 | Icons.arrow_upward, 84 | color: Colors.white54, 85 | size: 128.0, 86 | ), 87 | ), 88 | ) 89 | : SizedBox(), 90 | ), 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'kotlin-android' 26 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 27 | apply plugin: 'io.fabric' 28 | 29 | def keystoreProperties = new Properties() 30 | def keystorePropertiesFile = rootProject.file('key.properties') 31 | if (keystorePropertiesFile.exists()) { 32 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 33 | } 34 | 35 | android { 36 | compileSdkVersion 28 37 | 38 | sourceSets { 39 | main.java.srcDirs += 'src/main/kotlin' 40 | } 41 | 42 | lintOptions { 43 | disable 'InvalidPackage' 44 | } 45 | 46 | defaultConfig { 47 | applicationId "me.wolszon.reddigram" 48 | minSdkVersion 21 49 | targetSdkVersion 28 50 | versionCode flutterVersionCode.toInteger() 51 | versionName flutterVersionName 52 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 53 | } 54 | 55 | signingConfigs { 56 | release { 57 | keyAlias keystoreProperties['keyAlias'] 58 | keyPassword keystoreProperties['keyPassword'] 59 | storeFile file(keystoreProperties['storeFile']) 60 | storePassword keystoreProperties['storePassword'] 61 | } 62 | } 63 | buildTypes { 64 | release { 65 | signingConfig signingConfigs.release 66 | 67 | minifyEnabled true 68 | useProguard true 69 | 70 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 71 | } 72 | 73 | debug { 74 | signingConfig signingConfigs.release 75 | 76 | debuggable true 77 | } 78 | } 79 | } 80 | 81 | flutter { 82 | source '../..' 83 | } 84 | 85 | dependencies { 86 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 87 | testImplementation 'junit:junit:4.12' 88 | androidTestImplementation 'androidx.test:runner:1.1.0' 89 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0' 90 | } 91 | 92 | apply plugin: 'com.google.gms.google-services' 93 | -------------------------------------------------------------------------------- /lib/widgets/photo_grid_item.dart: -------------------------------------------------------------------------------- 1 | import 'package:cached_network_image/cached_network_image.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:reddigram/models/models.dart'; 4 | import 'package:reddigram/widgets/widgets.dart'; 5 | 6 | class PhotoGridItem extends StatelessWidget { 7 | final Photo photo; 8 | final VoidCallback onTap; 9 | 10 | final bool showNsfw; 11 | final VoidCallback onShowNsfw; 12 | 13 | const PhotoGridItem( 14 | {Key key, 15 | @required this.photo, 16 | this.onTap, 17 | this.showNsfw = false, 18 | this.onShowNsfw}) 19 | : assert(photo != null), 20 | super(key: key); 21 | 22 | static Widget placeholder({Key key}) => _Placeholder(key: key); 23 | 24 | @override 25 | Widget build(BuildContext context) { 26 | return Padding( 27 | padding: const EdgeInsets.all(2.0), 28 | child: ClipRRect( 29 | borderRadius: BorderRadius.circular(8.0), 30 | child: Stack( 31 | children: [ 32 | GestureDetector( 33 | onTap: onTap, 34 | child: CachedNetworkImage( 35 | imageUrl: photo.thumbnail.url, 36 | fit: BoxFit.cover, 37 | width: double.infinity, 38 | height: double.infinity, 39 | placeholder: (context, _) => _Placeholder(), 40 | fadeInDuration: Duration.zero, 41 | ), 42 | ), 43 | if (photo.nsfw) NsfwOverlay(show: showNsfw, onShow: onShowNsfw), 44 | ], 45 | ), 46 | ), 47 | ); 48 | } 49 | } 50 | 51 | class _Placeholder extends StatefulWidget { 52 | const _Placeholder({Key key}) : super(key: key); 53 | 54 | @override 55 | _PlaceholderState createState() => _PlaceholderState(); 56 | } 57 | 58 | class _PlaceholderState extends State<_Placeholder> 59 | with SingleTickerProviderStateMixin { 60 | Animation _animation; 61 | AnimationController _controller; 62 | 63 | @override 64 | void initState() { 65 | super.initState(); 66 | 67 | _controller = AnimationController(vsync: this) 68 | ..addListener(() => setState(() {})); 69 | _animation = Tween(begin: 1, end: 0.5).animate(CurvedAnimation( 70 | parent: _controller, 71 | curve: const Cubic(1, 0, .8, 1), 72 | )); 73 | 74 | _controller.repeat( 75 | period: const Duration(milliseconds: 1500), reverse: true); 76 | } 77 | 78 | @override 79 | void dispose() { 80 | _controller.dispose(); 81 | super.dispose(); 82 | } 83 | 84 | @override 85 | Widget build(BuildContext context) { 86 | final color = Theme.of(context) 87 | .textTheme 88 | .caption 89 | .color 90 | .withOpacity(0.2 * _animation.value); 91 | 92 | return Container( 93 | decoration: BoxDecoration( 94 | borderRadius: BorderRadius.circular(8.0), 95 | color: color, 96 | ), 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/app.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:firebase_analytics/firebase_analytics.dart'; 4 | import 'package:firebase_analytics/observer.dart'; 5 | import 'package:flutter/material.dart'; 6 | import 'package:flutter/services.dart'; 7 | import 'package:flutter_redux/flutter_redux.dart'; 8 | import 'package:reddigram/store/store.dart'; 9 | import 'package:reddigram/theme.dart'; 10 | import 'package:reddigram/screens/screens.dart'; 11 | import 'package:reddigram/widgets/widgets.dart'; 12 | import 'package:redux/redux.dart'; 13 | import 'package:uni_links/uni_links.dart'; 14 | 15 | class ReddigramApp extends StatefulWidget { 16 | static final analytics = FirebaseAnalytics(); 17 | static final _navObserver = FirebaseAnalyticsObserver(analytics: analytics); 18 | 19 | final Store store; 20 | 21 | ReddigramApp({Key key, @required this.store}) 22 | : assert(store != null), 23 | super(key: key); 24 | 25 | @override 26 | _ReddigramAppState createState() => _ReddigramAppState(); 27 | } 28 | 29 | class _ReddigramAppState extends State { 30 | StreamSubscription _linkStream; 31 | 32 | @override 33 | void initState() { 34 | super.initState(); 35 | 36 | _linkStream = getUriLinksStream().listen((uri) { 37 | if (uri.host == 'redirect' && uri.queryParameters.containsKey('code')) { 38 | widget.store 39 | .dispatch(authenticateUserFromCode(uri.queryParameters['code'])); 40 | 41 | ReddigramApp.analytics.logLogin(loginMethod: 'Reddit'); 42 | } 43 | }); 44 | } 45 | 46 | @override 47 | void dispose() { 48 | _linkStream?.cancel(); 49 | super.dispose(); 50 | } 51 | 52 | @override 53 | Widget build(BuildContext context) { 54 | SystemChrome.setPreferredOrientations([ 55 | DeviceOrientation.portraitUp, 56 | DeviceOrientation.portraitDown, 57 | ]); 58 | 59 | return StoreProvider( 60 | store: widget.store, 61 | child: StoreConnector( 62 | onInit: (store) => store.dispatch(loadPreferences()), 63 | converter: (store) => store.state.preferences, 64 | builder: (context, preferences) { 65 | return PreferencesProvider( 66 | preferences: preferences, 67 | child: StoreConnector( 68 | onInit: (store) => store.dispatch(loadUser()), 69 | converter: (store) => store.state.authState.status, 70 | builder: (context, authStatus) { 71 | return MaterialApp( 72 | title: 'Reddigram', 73 | theme: PreferencesProvider.of(context).theme == AppTheme.light 74 | ? ReddigramTheme.light() 75 | : ReddigramTheme.dark(), 76 | routes: { 77 | '/': (context) => MainScreen(), 78 | }, 79 | navigatorObservers: [ReddigramApp._navObserver], 80 | ); 81 | }, 82 | ), 83 | ); 84 | }, 85 | ), 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/me/wolszon/reddigram/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.wolszon.reddigram 2 | 3 | import android.Manifest 4 | import android.app.DownloadManager 5 | import android.content.Context 6 | import android.content.pm.PackageManager 7 | import android.net.Uri 8 | import android.os.Bundle 9 | import android.os.Environment 10 | import androidx.core.app.ActivityCompat 11 | import androidx.core.content.ContextCompat 12 | 13 | import io.flutter.app.FlutterActivity 14 | import io.flutter.plugin.common.MethodChannel 15 | import io.flutter.plugins.GeneratedPluginRegistrant 16 | import java.io.File 17 | 18 | class MainActivity : FlutterActivity() { 19 | private var photoDownloadUrl: String? = null 20 | 21 | companion object { 22 | private const val CHANNEL = "me.wolszon.reddigram" 23 | private const val WRITE_STORAGE_DOWNLOAD_PHOTO_CODE = 2 24 | } 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | GeneratedPluginRegistrant.registerWith(this) 29 | 30 | MethodChannel(flutterView, CHANNEL).setMethodCallHandler { call, result -> 31 | when (call.method) { 32 | "downloadPhoto" -> { 33 | val photoUrl = call.argument("url") 34 | if (photoUrl.isNullOrEmpty()) { 35 | result.error("No photoUrl", null, null) 36 | return@setMethodCallHandler 37 | } 38 | 39 | result.success(null) 40 | 41 | photoDownloadUrl = photoUrl 42 | downloadPhoto(photoUrl) 43 | } 44 | else -> result.notImplemented() 45 | } 46 | } 47 | } 48 | 49 | private fun downloadPhoto(url: String) { 50 | if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) 51 | != PackageManager.PERMISSION_GRANTED) { 52 | ActivityCompat.requestPermissions( 53 | this, 54 | arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 55 | WRITE_STORAGE_DOWNLOAD_PHOTO_CODE) 56 | } else { 57 | val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 58 | val photoUri = Uri.parse(url) 59 | 60 | val request = DownloadManager.Request(photoUri).apply { 61 | setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, 62 | "Glance" + File.separator + photoUri.lastPathSegment) 63 | setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) 64 | } 65 | 66 | downloadManager.enqueue(request) 67 | } 68 | } 69 | 70 | override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { 71 | when (requestCode) { 72 | WRITE_STORAGE_DOWNLOAD_PHOTO_CODE -> { 73 | if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { 74 | downloadPhoto(photoDownloadUrl!!) 75 | } 76 | } 77 | else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /lib/models/feed.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'feed.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$Feed extends Feed { 10 | @override 11 | final String name; 12 | @override 13 | final BuiltList photosIds; 14 | 15 | factory _$Feed([void Function(FeedBuilder) updates]) => 16 | (new FeedBuilder()..update(updates)).build(); 17 | 18 | _$Feed._({this.name, this.photosIds}) : super._() { 19 | if (name == null) { 20 | throw new BuiltValueNullFieldError('Feed', 'name'); 21 | } 22 | if (photosIds == null) { 23 | throw new BuiltValueNullFieldError('Feed', 'photosIds'); 24 | } 25 | } 26 | 27 | @override 28 | Feed rebuild(void Function(FeedBuilder) updates) => 29 | (toBuilder()..update(updates)).build(); 30 | 31 | @override 32 | FeedBuilder toBuilder() => new FeedBuilder()..replace(this); 33 | 34 | @override 35 | bool operator ==(Object other) { 36 | if (identical(other, this)) return true; 37 | return other is Feed && name == other.name && photosIds == other.photosIds; 38 | } 39 | 40 | @override 41 | int get hashCode { 42 | return $jf($jc($jc(0, name.hashCode), photosIds.hashCode)); 43 | } 44 | 45 | @override 46 | String toString() { 47 | return (newBuiltValueToStringHelper('Feed') 48 | ..add('name', name) 49 | ..add('photosIds', photosIds)) 50 | .toString(); 51 | } 52 | } 53 | 54 | class FeedBuilder implements Builder { 55 | _$Feed _$v; 56 | 57 | String _name; 58 | String get name => _$this._name; 59 | set name(String name) => _$this._name = name; 60 | 61 | ListBuilder _photosIds; 62 | ListBuilder get photosIds => 63 | _$this._photosIds ??= new ListBuilder(); 64 | set photosIds(ListBuilder photosIds) => _$this._photosIds = photosIds; 65 | 66 | FeedBuilder(); 67 | 68 | FeedBuilder get _$this { 69 | if (_$v != null) { 70 | _name = _$v.name; 71 | _photosIds = _$v.photosIds?.toBuilder(); 72 | _$v = null; 73 | } 74 | return this; 75 | } 76 | 77 | @override 78 | void replace(Feed other) { 79 | if (other == null) { 80 | throw new ArgumentError.notNull('other'); 81 | } 82 | _$v = other as _$Feed; 83 | } 84 | 85 | @override 86 | void update(void Function(FeedBuilder) updates) { 87 | if (updates != null) updates(this); 88 | } 89 | 90 | @override 91 | _$Feed build() { 92 | _$Feed _$result; 93 | try { 94 | _$result = _$v ?? new _$Feed._(name: name, photosIds: photosIds.build()); 95 | } catch (_) { 96 | String _$failedField; 97 | try { 98 | _$failedField = 'photosIds'; 99 | photosIds.build(); 100 | } catch (e) { 101 | throw new BuiltValueNestedFieldError( 102 | 'Feed', _$failedField, e.toString()); 103 | } 104 | rethrow; 105 | } 106 | replace(_$result); 107 | return _$result; 108 | } 109 | } 110 | 111 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 112 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 56 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 75 | 77 | 83 | 84 | 85 | 86 | 88 | 89 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /lib/store/feeds/actions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:reddigram/api/api.dart'; 4 | import 'package:reddigram/models/models.dart'; 5 | import 'package:reddigram/store/photos/actions.dart'; 6 | import 'package:reddigram/store/store.dart'; 7 | import 'package:redux/redux.dart'; 8 | import 'package:redux_thunk/redux_thunk.dart'; 9 | 10 | const POPULAR = 'POPULAR'; 11 | const NEW_SUBSCRIBED = 'NEW_SUBSCRIBED'; 12 | const BEST_SUBSCRIBED = 'BEST_SUBSCRIBED'; 13 | 14 | bool isSubreddit(String feed) => feed.contains(RegExp(r'^r\/')); 15 | 16 | String _getProperFeedName(Store store, String feed) { 17 | final subscriptions = store.state.subscriptions; 18 | final subscribedSubsNames = 19 | subscriptions.map((id) => store.state.subreddits[id].name); 20 | 21 | switch (feed) { 22 | case POPULAR: 23 | return '/r/popular'; 24 | case NEW_SUBSCRIBED: 25 | return subscriptions.isEmpty 26 | ? '_EMPTY' 27 | : 'r/' + subscribedSubsNames.join('+') + '/new'; 28 | case BEST_SUBSCRIBED: 29 | return subscriptions.isEmpty 30 | ? '_EMPTY' 31 | : 'r/' + subscribedSubsNames.join('+'); 32 | default: 33 | return feed; 34 | } 35 | } 36 | 37 | ThunkAction fetchFreshFeed(String feedName, 38 | {int limit, Completer completer}) { 39 | return (Store store) { 40 | redditRepository 41 | .feed(_getProperFeedName(store, feedName), limit: limit) 42 | .then((photos) async { 43 | store.dispatch(FetchedPhotos(photos)); 44 | 45 | final subredditIds = photos.map((photo) => photo.subredditId).toList(); 46 | final subredditCompleter = Completer(); 47 | store.dispatch( 48 | fetchSubreddits(subredditIds, completer: subredditCompleter)); 49 | 50 | await subredditCompleter.future; 51 | 52 | // If this feed is a subreddit, set feed's name to the subreddit's name 53 | // (thanks to this, feed has the same capitalization as the subreddit) 54 | if (isSubreddit(feedName)) { 55 | final subreditName = feedName.substring(2); 56 | 57 | feedName = 'r/' + 58 | store.state.subreddits.entries 59 | .firstWhere((entry) => 60 | entry.value.name.toLowerCase() == 61 | subreditName.toLowerCase()) 62 | .value 63 | .name; 64 | } 65 | 66 | final feed = Feed((b) => b 67 | ..name = feedName 68 | ..photosIds.replace(photosIds(photos))); 69 | 70 | store.dispatch(FetchedFreshFeed(feedName, feed)); 71 | }).whenComplete(() => completer?.complete()); 72 | }; 73 | } 74 | 75 | ThunkAction fetchMoreFeed(String feedName, 76 | {int limit, Completer completer}) { 77 | return (Store store) { 78 | final feed = store.state.feeds[feedName]; 79 | final after = feed.photosIds.isEmpty ? '' : feed.photosIds.last; 80 | 81 | redditRepository 82 | .feed(_getProperFeedName(store, feedName), after: after, limit: limit) 83 | .then((photos) async { 84 | store.dispatch(FetchedPhotos(photos)); 85 | store.dispatch(FetchedMoreFeed(feedName, photosIds(photos))); 86 | 87 | final subredditIds = photos.map((photo) => photo.subredditId).toList(); 88 | final subredditCompleter = Completer(); 89 | store.dispatch( 90 | fetchSubreddits(subredditIds, completer: subredditCompleter)); 91 | await subredditCompleter.future; 92 | }).whenComplete(() => completer?.complete()); 93 | }; 94 | } 95 | 96 | class FetchedFreshFeed { 97 | final String name; 98 | final Feed feed; 99 | 100 | FetchedFreshFeed(this.name, this.feed); 101 | } 102 | 103 | class FetchedMoreFeed { 104 | final String name; 105 | final List photosIds; 106 | 107 | FetchedMoreFeed(this.name, this.photosIds); 108 | } 109 | -------------------------------------------------------------------------------- /lib/store/auth/actions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:reddigram/api/api.dart'; 4 | import 'package:reddigram/store/store.dart'; 5 | import 'package:redux/redux.dart'; 6 | import 'package:redux_thunk/redux_thunk.dart'; 7 | import 'package:shared_preferences/shared_preferences.dart'; 8 | 9 | const _refreshTokenKey = 'reddit_refresh_token'; 10 | 11 | Future _loadFeeds(Store store) { 12 | // Fetch all feeds after we have info if user is authenticated or not. 13 | final bestCompleter = Completer(); 14 | store.dispatch(fetchFreshFeed(BEST_SUBSCRIBED, completer: bestCompleter)); 15 | final newCompleter = Completer(); 16 | store.dispatch(fetchFreshFeed(NEW_SUBSCRIBED, completer: newCompleter)); 17 | 18 | return Future.wait([ 19 | bestCompleter.future, 20 | newCompleter.future, 21 | ]); 22 | } 23 | 24 | void _loadUserData(Store store, String redditAccessToken) { 25 | final futures = []; 26 | 27 | futures.add(redditRepository 28 | .username() 29 | .then((username) => store.dispatch(SetUsername(username)))); 30 | 31 | final subscriptionsCompleter = Completer(); 32 | futures.add(subscriptionsCompleter.future); 33 | futures.add(apiRepository.useApi(redditAccessToken).then( 34 | (_) => store.dispatch(fetchSubscriptions(subscriptionsCompleter)), 35 | onError: (_) => subscriptionsCompleter.complete(), 36 | )); 37 | 38 | Future.wait(futures).then((_) async => await _loadFeeds(store)).then( 39 | (_) => store.dispatch(SetAuthStatus(AuthStatus.authenticated)), 40 | onError: (_) => store.dispatch(SetAuthStatus(AuthStatus.guest)), 41 | ); 42 | } 43 | 44 | ThunkAction loadUser() { 45 | return (Store store) { 46 | SharedPreferences.getInstance().then((prefs) async { 47 | final refreshToken = prefs.getString(_refreshTokenKey); 48 | 49 | if (refreshToken != null) { 50 | store.dispatch(SetAuthStatus(AuthStatus.authenticating)); 51 | 52 | final tokens = await redditRepository.refreshAccessToken(refreshToken); 53 | _loadUserData(store, tokens.accessToken); 54 | } else { 55 | apiRepository.useLocal(); 56 | final subscriptionsCompleter = Completer(); 57 | store.dispatch(fetchSubscriptions(subscriptionsCompleter)); 58 | await subscriptionsCompleter.future; 59 | await _loadFeeds(store); 60 | 61 | store.dispatch(SetAuthStatus(AuthStatus.guest)); 62 | } 63 | }); 64 | }; 65 | } 66 | 67 | ThunkAction authenticateUserFromCode(String code) { 68 | return (Store store) async { 69 | store.dispatch(SetAuthStatus(AuthStatus.authenticating)); 70 | 71 | final tokens = await redditRepository.retrieveTokens(code); 72 | SharedPreferences.getInstance().then( 73 | (prefs) => prefs.setString(_refreshTokenKey, tokens.refreshToken)); 74 | 75 | _loadUserData(store, tokens.accessToken); 76 | }; 77 | } 78 | 79 | ThunkAction signUserOut() { 80 | return (Store store) async { 81 | store.dispatch(SetAuthStatus(AuthStatus.signingOut)); 82 | 83 | redditRepository.clearTokens(); 84 | SharedPreferences.getInstance() 85 | .then((prefs) => prefs.remove(_refreshTokenKey)); 86 | store.dispatch(SetUsername(null)); 87 | 88 | apiRepository.useLocal(); 89 | 90 | final subscriptionsCompleter = Completer(); 91 | store.dispatch(fetchSubscriptions(subscriptionsCompleter)); 92 | await subscriptionsCompleter.future; 93 | 94 | _loadFeeds(store) 95 | .whenComplete(() => store.dispatch(SetAuthStatus(AuthStatus.guest))); 96 | }; 97 | } 98 | 99 | class SetUsername { 100 | final String username; 101 | 102 | SetUsername(this.username); 103 | } 104 | 105 | class SetAuthStatus { 106 | final AuthStatus status; 107 | 108 | SetAuthStatus(this.status); 109 | } 110 | -------------------------------------------------------------------------------- /lib/store/auth/auth_state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'auth_state.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | const AuthStatus _$unknown = const AuthStatus._('unknown'); 10 | const AuthStatus _$guest = const AuthStatus._('guest'); 11 | const AuthStatus _$authenticating = const AuthStatus._('authenticating'); 12 | const AuthStatus _$authenticated = const AuthStatus._('authenticated'); 13 | const AuthStatus _$signingOut = const AuthStatus._('signingOut'); 14 | 15 | AuthStatus _$valueOf(String name) { 16 | switch (name) { 17 | case 'unknown': 18 | return _$unknown; 19 | case 'guest': 20 | return _$guest; 21 | case 'authenticating': 22 | return _$authenticating; 23 | case 'authenticated': 24 | return _$authenticated; 25 | case 'signingOut': 26 | return _$signingOut; 27 | default: 28 | throw new ArgumentError(name); 29 | } 30 | } 31 | 32 | final BuiltSet _$values = 33 | new BuiltSet(const [ 34 | _$unknown, 35 | _$guest, 36 | _$authenticating, 37 | _$authenticated, 38 | _$signingOut, 39 | ]); 40 | 41 | class _$AuthState extends AuthState { 42 | @override 43 | final String username; 44 | @override 45 | final AuthStatus status; 46 | 47 | factory _$AuthState([void Function(AuthStateBuilder) updates]) => 48 | (new AuthStateBuilder()..update(updates)).build(); 49 | 50 | _$AuthState._({this.username, this.status}) : super._() { 51 | if (status == null) { 52 | throw new BuiltValueNullFieldError('AuthState', 'status'); 53 | } 54 | } 55 | 56 | @override 57 | AuthState rebuild(void Function(AuthStateBuilder) updates) => 58 | (toBuilder()..update(updates)).build(); 59 | 60 | @override 61 | AuthStateBuilder toBuilder() => new AuthStateBuilder()..replace(this); 62 | 63 | @override 64 | bool operator ==(Object other) { 65 | if (identical(other, this)) return true; 66 | return other is AuthState && 67 | username == other.username && 68 | status == other.status; 69 | } 70 | 71 | @override 72 | int get hashCode { 73 | return $jf($jc($jc(0, username.hashCode), status.hashCode)); 74 | } 75 | 76 | @override 77 | String toString() { 78 | return (newBuiltValueToStringHelper('AuthState') 79 | ..add('username', username) 80 | ..add('status', status)) 81 | .toString(); 82 | } 83 | } 84 | 85 | class AuthStateBuilder implements Builder { 86 | _$AuthState _$v; 87 | 88 | String _username; 89 | String get username => _$this._username; 90 | set username(String username) => _$this._username = username; 91 | 92 | AuthStatus _status; 93 | AuthStatus get status => _$this._status; 94 | set status(AuthStatus status) => _$this._status = status; 95 | 96 | AuthStateBuilder(); 97 | 98 | AuthStateBuilder get _$this { 99 | if (_$v != null) { 100 | _username = _$v.username; 101 | _status = _$v.status; 102 | _$v = null; 103 | } 104 | return this; 105 | } 106 | 107 | @override 108 | void replace(AuthState other) { 109 | if (other == null) { 110 | throw new ArgumentError.notNull('other'); 111 | } 112 | _$v = other as _$AuthState; 113 | } 114 | 115 | @override 116 | void update(void Function(AuthStateBuilder) updates) { 117 | if (updates != null) updates(this); 118 | } 119 | 120 | @override 121 | _$AuthState build() { 122 | final _$result = 123 | _$v ?? new _$AuthState._(username: username, status: status); 124 | replace(_$result); 125 | return _$result; 126 | } 127 | } 128 | 129 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 130 | -------------------------------------------------------------------------------- /lib/store/subreddits_search/subreddits_search_state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'subreddits_search_state.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$SubredditsSearchState extends SubredditsSearchState { 10 | @override 11 | final String lastQuery; 12 | @override 13 | final BuiltList resultFeedsIds; 14 | 15 | factory _$SubredditsSearchState( 16 | [void Function(SubredditsSearchStateBuilder) updates]) => 17 | (new SubredditsSearchStateBuilder()..update(updates)).build(); 18 | 19 | _$SubredditsSearchState._({this.lastQuery, this.resultFeedsIds}) : super._() { 20 | if (lastQuery == null) { 21 | throw new BuiltValueNullFieldError('SubredditsSearchState', 'lastQuery'); 22 | } 23 | if (resultFeedsIds == null) { 24 | throw new BuiltValueNullFieldError( 25 | 'SubredditsSearchState', 'resultFeedsIds'); 26 | } 27 | } 28 | 29 | @override 30 | SubredditsSearchState rebuild( 31 | void Function(SubredditsSearchStateBuilder) updates) => 32 | (toBuilder()..update(updates)).build(); 33 | 34 | @override 35 | SubredditsSearchStateBuilder toBuilder() => 36 | new SubredditsSearchStateBuilder()..replace(this); 37 | 38 | @override 39 | bool operator ==(Object other) { 40 | if (identical(other, this)) return true; 41 | return other is SubredditsSearchState && 42 | lastQuery == other.lastQuery && 43 | resultFeedsIds == other.resultFeedsIds; 44 | } 45 | 46 | @override 47 | int get hashCode { 48 | return $jf($jc($jc(0, lastQuery.hashCode), resultFeedsIds.hashCode)); 49 | } 50 | 51 | @override 52 | String toString() { 53 | return (newBuiltValueToStringHelper('SubredditsSearchState') 54 | ..add('lastQuery', lastQuery) 55 | ..add('resultFeedsIds', resultFeedsIds)) 56 | .toString(); 57 | } 58 | } 59 | 60 | class SubredditsSearchStateBuilder 61 | implements Builder { 62 | _$SubredditsSearchState _$v; 63 | 64 | String _lastQuery; 65 | String get lastQuery => _$this._lastQuery; 66 | set lastQuery(String lastQuery) => _$this._lastQuery = lastQuery; 67 | 68 | ListBuilder _resultFeedsIds; 69 | ListBuilder get resultFeedsIds => 70 | _$this._resultFeedsIds ??= new ListBuilder(); 71 | set resultFeedsIds(ListBuilder resultFeedsIds) => 72 | _$this._resultFeedsIds = resultFeedsIds; 73 | 74 | SubredditsSearchStateBuilder(); 75 | 76 | SubredditsSearchStateBuilder get _$this { 77 | if (_$v != null) { 78 | _lastQuery = _$v.lastQuery; 79 | _resultFeedsIds = _$v.resultFeedsIds?.toBuilder(); 80 | _$v = null; 81 | } 82 | return this; 83 | } 84 | 85 | @override 86 | void replace(SubredditsSearchState other) { 87 | if (other == null) { 88 | throw new ArgumentError.notNull('other'); 89 | } 90 | _$v = other as _$SubredditsSearchState; 91 | } 92 | 93 | @override 94 | void update(void Function(SubredditsSearchStateBuilder) updates) { 95 | if (updates != null) updates(this); 96 | } 97 | 98 | @override 99 | _$SubredditsSearchState build() { 100 | _$SubredditsSearchState _$result; 101 | try { 102 | _$result = _$v ?? 103 | new _$SubredditsSearchState._( 104 | lastQuery: lastQuery, resultFeedsIds: resultFeedsIds.build()); 105 | } catch (_) { 106 | String _$failedField; 107 | try { 108 | _$failedField = 'resultFeedsIds'; 109 | resultFeedsIds.build(); 110 | } catch (e) { 111 | throw new BuiltValueNestedFieldError( 112 | 'SubredditsSearchState', _$failedField, e.toString()); 113 | } 114 | rethrow; 115 | } 116 | replace(_$result); 117 | return _$result; 118 | } 119 | } 120 | 121 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 122 | -------------------------------------------------------------------------------- /lib/api/response_models/link_listing_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:built_value/built_value.dart'; 2 | import 'package:built_value/serializer.dart'; 3 | import 'package:built_collection/built_collection.dart'; 4 | 5 | part 'link_listing_response.g.dart'; 6 | 7 | abstract class LinkListingResponse 8 | implements Built { 9 | LinkListingData get data; 10 | 11 | LinkListingResponse._(); 12 | 13 | factory LinkListingResponse([updates(LinkListingResponseBuilder b)]) = 14 | _$LinkListingResponse; 15 | 16 | static Serializer get serializer => 17 | _$linkListingResponseSerializer; 18 | } 19 | 20 | abstract class LinkListingData 21 | implements Built { 22 | @nullable 23 | String get after; 24 | 25 | @nullable 26 | String get before; 27 | 28 | BuiltList get children; 29 | 30 | LinkListingData._(); 31 | 32 | factory LinkListingData([updates(LinkListingDataBuilder b)]) = 33 | _$LinkListingData; 34 | 35 | static Serializer get serializer => 36 | _$linkListingDataSerializer; 37 | } 38 | 39 | abstract class LinkChild implements Built { 40 | LinkChildData get data; 41 | 42 | LinkChild._(); 43 | 44 | factory LinkChild([updates(LinkChildBuilder b)]) = _$LinkChild; 45 | 46 | static Serializer get serializer => _$linkChildSerializer; 47 | } 48 | 49 | abstract class LinkChildData 50 | implements Built { 51 | String get name; 52 | 53 | String get title; 54 | 55 | String get subreddit; 56 | 57 | @BuiltValueField(wireName: 'subreddit_id') 58 | String get subredditId; 59 | 60 | String get permalink; 61 | 62 | String get author; 63 | 64 | int get score; 65 | 66 | @nullable 67 | bool get likes; 68 | 69 | String get thumbnail; 70 | 71 | @BuiltValueField(wireName: 'thumbnail_width') 72 | @nullable 73 | int get thumbnailWidth; 74 | 75 | @BuiltValueField(wireName: 'thumbnail_height') 76 | @nullable 77 | int get thumbnailHeight; 78 | 79 | @BuiltValueField(wireName: 'over_18') 80 | bool get over18; 81 | 82 | @nullable 83 | Preview get preview; 84 | 85 | @nullable 86 | Media get media; 87 | 88 | @BuiltValueField(wireName: 'is_video') 89 | bool get isVideo; 90 | 91 | LinkChildData._(); 92 | 93 | factory LinkChildData([updates(LinkChildDataBuilder b)]) = _$LinkChildData; 94 | 95 | static Serializer get serializer => _$linkChildDataSerializer; 96 | } 97 | 98 | abstract class Preview implements Built { 99 | BuiltList get images; 100 | 101 | @BuiltValueField(wireName: 'reddit_video_preview') 102 | @nullable 103 | RedditVideo get redditVideoPreview; 104 | 105 | Preview._(); 106 | 107 | factory Preview([updates(PreviewBuilder b)]) = _$Preview; 108 | 109 | static Serializer get serializer => _$previewSerializer; 110 | } 111 | 112 | abstract class PreviewImage 113 | implements Built { 114 | Image get source; 115 | 116 | BuiltList get resolutions; 117 | 118 | PreviewImage._(); 119 | 120 | factory PreviewImage([updates(PreviewImageBuilder b)]) = _$PreviewImage; 121 | 122 | static Serializer get serializer => _$previewImageSerializer; 123 | } 124 | 125 | abstract class Image implements Built { 126 | String get url; 127 | 128 | int get width; 129 | 130 | int get height; 131 | 132 | Image._(); 133 | 134 | factory Image([updates(ImageBuilder b)]) = _$Image; 135 | 136 | static Serializer get serializer => _$imageSerializer; 137 | } 138 | 139 | abstract class Media implements Built { 140 | @BuiltValueField(wireName: 'reddit_video') 141 | @nullable 142 | RedditVideo get redditVideo; 143 | 144 | Media._(); 145 | 146 | factory Media([updates(MediaBuilder b)]) = _$Media; 147 | 148 | static Serializer get serializer => _$mediaSerializer; 149 | } 150 | 151 | abstract class RedditVideo implements Built { 152 | @BuiltValueField(wireName: 'fallback_url') 153 | String get fallbackUrl; 154 | 155 | RedditVideo._(); 156 | 157 | factory RedditVideo([updates(RedditVideoBuilder b)]) = _$RedditVideo; 158 | 159 | static Serializer get serializer => _$redditVideoSerializer; 160 | } 161 | -------------------------------------------------------------------------------- /lib/store/preferences/preferences_state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'preferences_state.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | const AppTheme _$light = const AppTheme._('light'); 10 | const AppTheme _$dark = const AppTheme._('dark'); 11 | 12 | AppTheme _$valueOf(String name) { 13 | switch (name) { 14 | case 'light': 15 | return _$light; 16 | case 'dark': 17 | return _$dark; 18 | default: 19 | throw new ArgumentError(name); 20 | } 21 | } 22 | 23 | final BuiltSet _$values = new BuiltSet(const [ 24 | _$light, 25 | _$dark, 26 | ]); 27 | 28 | class _$PreferencesState extends PreferencesState { 29 | @override 30 | final AppTheme theme; 31 | @override 32 | final bool showNsfw; 33 | @override 34 | final bool cutLongPhotos; 35 | 36 | factory _$PreferencesState( 37 | [void Function(PreferencesStateBuilder) updates]) => 38 | (new PreferencesStateBuilder()..update(updates)).build(); 39 | 40 | _$PreferencesState._({this.theme, this.showNsfw, this.cutLongPhotos}) 41 | : super._() { 42 | if (theme == null) { 43 | throw new BuiltValueNullFieldError('PreferencesState', 'theme'); 44 | } 45 | if (showNsfw == null) { 46 | throw new BuiltValueNullFieldError('PreferencesState', 'showNsfw'); 47 | } 48 | if (cutLongPhotos == null) { 49 | throw new BuiltValueNullFieldError('PreferencesState', 'cutLongPhotos'); 50 | } 51 | } 52 | 53 | @override 54 | PreferencesState rebuild(void Function(PreferencesStateBuilder) updates) => 55 | (toBuilder()..update(updates)).build(); 56 | 57 | @override 58 | PreferencesStateBuilder toBuilder() => 59 | new PreferencesStateBuilder()..replace(this); 60 | 61 | @override 62 | bool operator ==(Object other) { 63 | if (identical(other, this)) return true; 64 | return other is PreferencesState && 65 | theme == other.theme && 66 | showNsfw == other.showNsfw && 67 | cutLongPhotos == other.cutLongPhotos; 68 | } 69 | 70 | @override 71 | int get hashCode { 72 | return $jf($jc($jc($jc(0, theme.hashCode), showNsfw.hashCode), 73 | cutLongPhotos.hashCode)); 74 | } 75 | 76 | @override 77 | String toString() { 78 | return (newBuiltValueToStringHelper('PreferencesState') 79 | ..add('theme', theme) 80 | ..add('showNsfw', showNsfw) 81 | ..add('cutLongPhotos', cutLongPhotos)) 82 | .toString(); 83 | } 84 | } 85 | 86 | class PreferencesStateBuilder 87 | implements Builder { 88 | _$PreferencesState _$v; 89 | 90 | AppTheme _theme; 91 | AppTheme get theme => _$this._theme; 92 | set theme(AppTheme theme) => _$this._theme = theme; 93 | 94 | bool _showNsfw; 95 | bool get showNsfw => _$this._showNsfw; 96 | set showNsfw(bool showNsfw) => _$this._showNsfw = showNsfw; 97 | 98 | bool _cutLongPhotos; 99 | bool get cutLongPhotos => _$this._cutLongPhotos; 100 | set cutLongPhotos(bool cutLongPhotos) => 101 | _$this._cutLongPhotos = cutLongPhotos; 102 | 103 | PreferencesStateBuilder(); 104 | 105 | PreferencesStateBuilder get _$this { 106 | if (_$v != null) { 107 | _theme = _$v.theme; 108 | _showNsfw = _$v.showNsfw; 109 | _cutLongPhotos = _$v.cutLongPhotos; 110 | _$v = null; 111 | } 112 | return this; 113 | } 114 | 115 | @override 116 | void replace(PreferencesState other) { 117 | if (other == null) { 118 | throw new ArgumentError.notNull('other'); 119 | } 120 | _$v = other as _$PreferencesState; 121 | } 122 | 123 | @override 124 | void update(void Function(PreferencesStateBuilder) updates) { 125 | if (updates != null) updates(this); 126 | } 127 | 128 | @override 129 | _$PreferencesState build() { 130 | final _$result = _$v ?? 131 | new _$PreferencesState._( 132 | theme: theme, showNsfw: showNsfw, cutLongPhotos: cutLongPhotos); 133 | replace(_$result); 134 | return _$result; 135 | } 136 | } 137 | 138 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 139 | -------------------------------------------------------------------------------- /assets/fonts/Pacifico/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2011 The Pacifico Project Authors (https://github.com/Fonthausen/Pacifico) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /lib/screens/feed_tab.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_redux/flutter_redux.dart'; 5 | import 'package:reddigram/models/models.dart'; 6 | import 'package:reddigram/screens/screens.dart'; 7 | import 'package:reddigram/store/store.dart'; 8 | import 'package:reddigram/widgets/widgets.dart'; 9 | import 'package:redux/redux.dart'; 10 | 11 | class FeedTab extends StatefulWidget { 12 | final String feedName; 13 | final Key infiniteListKey; 14 | final Widget placeholder; 15 | 16 | const FeedTab( 17 | {Key key, 18 | @required this.feedName, 19 | this.infiniteListKey, 20 | this.placeholder = const SizedBox()}) 21 | : assert(feedName != null), 22 | assert(placeholder != null), 23 | super(key: key); 24 | 25 | @override 26 | _FeedTabState createState() => _FeedTabState(); 27 | } 28 | 29 | class _FeedTabState extends State { 30 | Set _shownNsfwIds = Set(); 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | return StoreConnector( 35 | converter: (store) => _BodyViewModel.fromStore(store, widget.feedName), 36 | builder: (context, vm) { 37 | return RefreshIndicator( 38 | onRefresh: () { 39 | final completer = Completer(); 40 | vm.fetchFresh(completer); 41 | 42 | return completer.future; 43 | }, 44 | child: vm.photos.isNotEmpty 45 | ? InfiniteList( 46 | key: widget.infiniteListKey, 47 | keepAlive: true, 48 | fetchMore: vm.fetchMore, 49 | itemCount: vm.photos.length + 1, 50 | itemBuilder: (context, i) { 51 | // Last item is a loading indicator. 52 | if (i == vm.photos.length) { 53 | return Container( 54 | padding: const EdgeInsets.symmetric(vertical: 32.0), 55 | alignment: Alignment.center, 56 | child: const CircularProgressIndicator(), 57 | ); 58 | } 59 | 60 | return _buildPhoto(context, i); 61 | }, 62 | ) 63 | : widget.placeholder, 64 | ); 65 | }, 66 | ); 67 | } 68 | 69 | Widget _buildPhoto(BuildContext context, int index) { 70 | return StoreConnector( 71 | converter: (store) => 72 | _PhotoViewModel.fromStore(store, widget.feedName, index), 73 | builder: (context, vm) => PhotoListItem( 74 | photo: vm.photo, 75 | subreddit: vm.subreddit, 76 | upvotingEnabled: vm.authenticated, 77 | onUpvote: vm.onUpvote, 78 | onUpvoteCanceled: vm.onUpvoteCanceled, 79 | onPhotoTap: () => 80 | Navigator.push(context, PhotoPreviewScreen.route(vm.photo)), 81 | onSubredditTap: () => Navigator.push( 82 | context, SubredditScreen.route(vm.photo.subredditId)), 83 | showNsfw: _shownNsfwIds.contains(vm.photo.id) || 84 | PreferencesProvider.of(context).showNsfw, 85 | onShowNsfw: () => setState(() => _shownNsfwIds.add(vm.photo.id)), 86 | ), 87 | ); 88 | } 89 | } 90 | 91 | class _BodyViewModel { 92 | final List photos; 93 | final void Function(Completer) fetchFresh; 94 | final void Function(Completer) fetchMore; 95 | 96 | _BodyViewModel( 97 | {@required this.photos, 98 | @required this.fetchFresh, 99 | @required this.fetchMore}) 100 | : assert(photos != null), 101 | assert(fetchFresh != null), 102 | assert(fetchMore != null); 103 | 104 | factory _BodyViewModel.fromStore( 105 | Store store, String feedName) { 106 | return _BodyViewModel( 107 | photos: store.state.feeds[feedName].photosIds 108 | .map((photoId) => store.state.photos[photoId]) 109 | .toList(), 110 | fetchFresh: (completer) => 111 | store.dispatch(fetchFreshFeed(feedName, completer: completer)), 112 | fetchMore: (completer) => 113 | store.dispatch(fetchMoreFeed(feedName, completer: completer)), 114 | ); 115 | } 116 | } 117 | 118 | class _PhotoViewModel { 119 | final bool authenticated; 120 | final Photo photo; 121 | final Subreddit subreddit; 122 | final VoidCallback onUpvote; 123 | final VoidCallback onUpvoteCanceled; 124 | 125 | _PhotoViewModel( 126 | {@required this.authenticated, 127 | @required this.photo, 128 | @required this.subreddit, 129 | @required this.onUpvote, 130 | @required this.onUpvoteCanceled}) 131 | : assert(authenticated != null), 132 | assert(photo != null); 133 | 134 | factory _PhotoViewModel.fromStore( 135 | Store store, String feedName, int index) { 136 | final photoId = store.state.feeds[feedName].photosIds[index]; 137 | final photo = store.state.photos[photoId]; 138 | final subreddit = store.state.subreddits[photo.subredditId]; 139 | 140 | return _PhotoViewModel( 141 | authenticated: store.state.authState.status == AuthStatus.authenticated, 142 | photo: photo, 143 | subreddit: subreddit, 144 | onUpvote: () => store.dispatch(upvote(photo)), 145 | onUpvoteCanceled: () => store.dispatch(cancelUpvote(photo)), 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/screens/preferences_sheet.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/gestures.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_redux/flutter_redux.dart'; 4 | import 'package:reddigram/app.dart'; 5 | import 'package:reddigram/consts.dart'; 6 | import 'package:reddigram/store/store.dart'; 7 | import 'package:reddigram/widgets/widgets.dart'; 8 | import 'package:url_launcher/url_launcher.dart'; 9 | 10 | class PreferencesSheet extends StatelessWidget { 11 | void _connectToReddit(BuildContext context) async { 12 | ReddigramApp.analytics.logEvent(name: 'login_attempt'); 13 | 14 | if (await _showRedditBugWarningAlert(context) != true) return; 15 | 16 | launch('https://www.reddit.com/api/v1/authorize' 17 | '?client_id=${GlanceConsts.oauthClientId}&response_type=code' 18 | '&state=x&scope=read+mysubreddits+vote+identity&duration=permanent' 19 | '&redirect_uri=${GlanceConsts.oauthRedirectUrl}'); 20 | } 21 | 22 | Future _showRedditBugWarningAlert(BuildContext context) async { 23 | return showDialog( 24 | context: context, 25 | builder: (BuildContext context) => AlertDialog( 26 | title: const Text('Warning'), 27 | content: Column( 28 | mainAxisSize: MainAxisSize.min, 29 | children: [ 30 | RichText( 31 | text: TextSpan( 32 | style: Theme.of(context).textTheme.body1, 33 | children: [ 34 | const TextSpan( 35 | text: 'Due to Reddit\'s bug, ' 36 | 'if you are unable to sign in please turn on '), 37 | const TextSpan( 38 | text: 'Desktop site', 39 | style: TextStyle(fontWeight: FontWeight.bold), 40 | ), 41 | const TextSpan(text: ' option and try again. '), 42 | TextSpan( 43 | text: 'Read more', 44 | style: TextStyle( 45 | color: Colors.blue, 46 | ), 47 | recognizer: TapGestureRecognizer() 48 | ..onTap = () => 49 | launch('https://www.reddit.com/r/bugs/comments/dc8cea' 50 | '/cant_sign_in_on_mobile_on_logindest/'), 51 | ), 52 | ], 53 | ), 54 | ), 55 | const SizedBox(height: 24), 56 | Image.asset('assets/chrome_desktop_site.png'), 57 | ], 58 | ), 59 | actions: [ 60 | FlatButton( 61 | child: const Text('OK'), 62 | onPressed: () => Navigator.pop(context, true), 63 | ), 64 | ], 65 | ), 66 | ); 67 | } 68 | 69 | void _signOut(BuildContext context) { 70 | StoreProvider.of(context).dispatch(signUserOut()); 71 | ReddigramApp.analytics.logEvent(name: 'sign_out'); 72 | } 73 | 74 | void _openPrivacyPolicy() async { 75 | launch('https://reddigram.wolszon.me/privacy'); 76 | ReddigramApp.analytics.logEvent(name: 'open_privacy_policy'); 77 | } 78 | 79 | @override 80 | Widget build(BuildContext context) { 81 | return Column( 82 | mainAxisSize: MainAxisSize.min, 83 | children: [ 84 | _buildConnectTile(), 85 | const ListTile( 86 | title: Text( 87 | 'PREFERENCES', 88 | style: TextStyle( 89 | fontWeight: FontWeight.bold, 90 | ), 91 | ), 92 | dense: true, 93 | ), 94 | const DarkThemePreferenceTile(), 95 | const ShowNsfwPreferenceTile(), 96 | _buildFooter(context), 97 | ], 98 | ); 99 | } 100 | 101 | Widget _buildConnectTile() { 102 | return StoreConnector( 103 | converter: (store) => store.state.authState, 104 | builder: (context, authState) => 105 | authState.status == AuthStatus.authenticated 106 | ? ListTile( 107 | title: const Text('Sign out'), 108 | trailing: Text(authState.username), 109 | leading: const Icon(Icons.power_settings_new), 110 | onTap: () => _signOut(context), 111 | ) 112 | : ListTile( 113 | title: const Text('Connect to Reddit'), 114 | leading: const Icon(Icons.account_circle), 115 | onTap: () => _connectToReddit(context), 116 | trailing: authState.status == AuthStatus.authenticating 117 | ? Transform.scale( 118 | scale: 0.5, 119 | child: const CircularProgressIndicator(), 120 | ) 121 | : null, 122 | ), 123 | ); 124 | } 125 | 126 | Widget _buildFooter(BuildContext context) { 127 | return ListTile( 128 | enabled: false, 129 | dense: true, 130 | title: RichText( 131 | text: TextSpan( 132 | style: Theme.of(context) 133 | .textTheme 134 | .body2 135 | .copyWith(color: Theme.of(context).disabledColor), 136 | children: [ 137 | const TextSpan(text: 'Glance • '), 138 | TextSpan( 139 | text: 'Privacy policy', 140 | style: const TextStyle( 141 | decoration: TextDecoration.underline, 142 | ), 143 | recognizer: TapGestureRecognizer()..onTap = _openPrivacyPolicy, 144 | ), 145 | ], 146 | ), 147 | ), 148 | ); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /lib/screens/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_redux/flutter_redux.dart'; 3 | import 'package:reddigram/screens/screens.dart'; 4 | import 'package:reddigram/store/store.dart'; 5 | import 'package:reddigram/widgets/reddigram_logo.dart'; 6 | import 'package:reddigram/widgets/widgets.dart'; 7 | 8 | class MainScreen extends StatefulWidget { 9 | static final scaffoldKey = GlobalKey(); 10 | 11 | @override 12 | _MainScreenState createState() => _MainScreenState(); 13 | } 14 | 15 | class _MainScreenState extends State { 16 | static const _TAB_POPULAR = 0; 17 | static const _TAB_BEST = 1; // ignore: unused_field 18 | static const _TAB_NEWEST = 2; // ignore: unused_field 19 | static const _TAB_SUBSCRIPTIONS = 3; 20 | 21 | final feedKeys = List.generate(3, (i) => GlobalKey()); 22 | 23 | final _pageController = PageController(); 24 | int _currentTab = _TAB_POPULAR; 25 | 26 | @override 27 | Widget build(BuildContext context) { 28 | final subheadTheme = Theme.of(context).textTheme.subhead; 29 | 30 | final subscribeCTA = GestureDetector( 31 | onTap: () => _changeTab(_TAB_SUBSCRIPTIONS), 32 | child: Column( 33 | mainAxisAlignment: MainAxisAlignment.center, 34 | children: [ 35 | Row( 36 | mainAxisAlignment: MainAxisAlignment.center, 37 | children: [ 38 | Text('No', style: subheadTheme), 39 | const Padding( 40 | padding: EdgeInsets.symmetric(horizontal: 8.0), 41 | child: Icon( 42 | Icons.short_text, 43 | size: 28.0, 44 | ), 45 | ), 46 | Text('yet.', style: subheadTheme) 47 | ], 48 | ), 49 | const SizedBox(height: 12.0), 50 | const Text('Subscribe to something!'), 51 | ], 52 | ), 53 | ); 54 | 55 | final itemsPlaceholder = ListView.builder( 56 | physics: const NeverScrollableScrollPhysics(), 57 | itemBuilder: (context, i) => PhotoListItem.placeholder(), 58 | ); 59 | 60 | return Scaffold( 61 | key: MainScreen.scaffoldKey, 62 | appBar: _buildAppBar(context), 63 | body: StoreConnector( 64 | onInit: (store) => store.dispatch(fetchFreshFeed(POPULAR)), 65 | converter: (store) => store.state.subscriptions.isNotEmpty, 66 | builder: (context, anySubs) => PageView( 67 | physics: const NeverScrollableScrollPhysics(), 68 | controller: _pageController, 69 | children: [ 70 | FeedTab( 71 | feedName: POPULAR, 72 | infiniteListKey: feedKeys[0], 73 | placeholder: itemsPlaceholder, 74 | ), 75 | FeedTab( 76 | feedName: BEST_SUBSCRIBED, 77 | infiniteListKey: feedKeys[1], 78 | placeholder: anySubs ? itemsPlaceholder : subscribeCTA, 79 | ), 80 | FeedTab( 81 | feedName: NEW_SUBSCRIBED, 82 | infiniteListKey: feedKeys[2], 83 | placeholder: anySubs ? itemsPlaceholder : subscribeCTA, 84 | ), 85 | const SubscriptionsTab(), 86 | ], 87 | ), 88 | ), 89 | bottomNavigationBar: IconNavigationBar( 90 | currentIndex: _currentTab, 91 | icons: [ 92 | const IconNavigationBarItem( 93 | icon: Icon(Icons.show_chart), 94 | tooltip: 'Popular', 95 | ), 96 | const IconNavigationBarItem( 97 | icon: Icon(Icons.whatshot), 98 | tooltip: 'Your best', 99 | ), 100 | const IconNavigationBarItem( 101 | icon: Icon(Icons.star), 102 | tooltip: 'Your newest', 103 | ), 104 | const IconNavigationBarItem( 105 | icon: Icon(Icons.short_text), 106 | tooltip: 'Subscriptions', 107 | ), 108 | ], 109 | onTap: (index) { 110 | if (_currentTab != index) { 111 | // Change the current tab 112 | _changeTab(index); 113 | } else { 114 | // Scroll to the top 115 | setState(() => feedKeys[index].currentState.scrollToOffset(0)); 116 | } 117 | }, 118 | ), 119 | ); 120 | } 121 | 122 | void _changeTab(int tab) { 123 | setState(() { 124 | _currentTab = tab; 125 | _pageController.animateToPage( 126 | tab, 127 | duration: const Duration(milliseconds: 300), 128 | curve: Curves.ease, 129 | ); 130 | }); 131 | } 132 | 133 | Widget _buildAppBar(BuildContext context) { 134 | return AppBar( 135 | title: const ReddigramLogo(), 136 | centerTitle: true, 137 | leading: _buildAccountLeadingIcon(context), 138 | ); 139 | } 140 | 141 | Widget _buildAccountLeadingIcon(BuildContext context) { 142 | return IconButton( 143 | icon: StoreConnector( 144 | converter: (store) => 145 | store.state.authState.status == AuthStatus.authenticated, 146 | builder: (context, signedIn) => AnimatedContainer( 147 | curve: Curves.ease, 148 | duration: const Duration(milliseconds: 300), 149 | decoration: BoxDecoration( 150 | shape: BoxShape.circle, 151 | border: Border.all( 152 | color: signedIn 153 | ? Theme.of(context).buttonTheme.colorScheme.primary 154 | : Colors.transparent, 155 | width: 3, 156 | ), 157 | ), 158 | child: const Icon(Icons.account_circle), 159 | ), 160 | ), 161 | onPressed: () { 162 | showModalBottomSheet( 163 | context: context, 164 | builder: (context) => PreferencesSheet(), 165 | ); 166 | }, 167 | ); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /lib/screens/import_subscriptions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_redux/flutter_redux.dart'; 5 | import 'package:reddigram/api/api.dart'; 6 | import 'package:reddigram/models/models.dart'; 7 | import 'package:reddigram/store/store.dart'; 8 | import 'package:reddigram/widgets/subreddit_circle_avatar.dart'; 9 | 10 | class ImportSubscriptionsScreen extends StatefulWidget { 11 | static PageRoute route() { 12 | return MaterialPageRoute( 13 | fullscreenDialog: true, 14 | builder: (context) => ImportSubscriptionsScreen(), 15 | ); 16 | } 17 | 18 | @override 19 | _ImportSubscriptionsScreenState createState() => 20 | _ImportSubscriptionsScreenState(); 21 | } 22 | 23 | class _ImportSubscriptionsScreenState extends State { 24 | Map _subscriptions = {}; 25 | bool _loading = true; 26 | bool _importing = false; 27 | 28 | @override 29 | void initState() { 30 | super.initState(); 31 | 32 | redditRepository.subscribedSubreddits().then((subreddits) { 33 | final store = StoreProvider.of(context); 34 | 35 | store.dispatch(FetchedSubreddits(subreddits)); 36 | setState(() { 37 | subreddits 38 | // Discard text subreddits 39 | .where((subreddit) => subreddit.submissionType != 'self') 40 | // Discard already subscribed subreddits 41 | .where((subreddit) => 42 | !store.state.subscriptions.contains(subreddit.id)) 43 | .forEach((subreddit) { 44 | _subscriptions[subreddit.id] = false; 45 | }); 46 | }); 47 | }).whenComplete(() => setState(() => _loading = false)); 48 | } 49 | 50 | void _import() { 51 | setState(() => _importing = true); 52 | 53 | final store = StoreProvider.of(context); 54 | _subscriptions.forEach((subredditId, import) async { 55 | if (!import) return; 56 | 57 | store.dispatch(subscribeSubreddit(subredditId)); 58 | }); 59 | 60 | setState(() => _importing = false); 61 | Navigator.pop(context); 62 | } 63 | 64 | @override 65 | Widget build(BuildContext context) { 66 | return Scaffold( 67 | appBar: AppBar( 68 | title: Column( 69 | mainAxisSize: MainAxisSize.min, 70 | crossAxisAlignment: CrossAxisAlignment.start, 71 | children: [ 72 | const Text('Import subscriptions'), 73 | Text( 74 | 'Already subscribed are not shown.', 75 | style: TextStyle( 76 | fontSize: 14, 77 | fontWeight: FontWeight.normal, 78 | color: Theme.of(context).textTheme.title.color.withOpacity(.7), 79 | ), 80 | ), 81 | ], 82 | ), 83 | actions: [ 84 | if (_subscriptions.isNotEmpty) 85 | IconButton( 86 | icon: Icon(Icons.select_all), 87 | onPressed: () { 88 | setState(() { 89 | // If everything is selected, deselect everything... 90 | if (_subscriptions.entries.every((entry) => entry.value)) { 91 | _subscriptions.updateAll((_, __) => false); 92 | } else { 93 | // ...select all otherwise. 94 | _subscriptions.updateAll((_, __) => true); 95 | } 96 | }); 97 | }, 98 | ), 99 | ], 100 | ), 101 | body: Column( 102 | children: [ 103 | Expanded( 104 | child: !_loading 105 | ? _subscriptions.isNotEmpty 106 | ? _buildList(context) 107 | : const ListTile(title: Text('No items to show')) 108 | : const Center(child: CircularProgressIndicator()), 109 | ), 110 | _buildFooter(context), 111 | ], 112 | ), 113 | ); 114 | } 115 | 116 | Widget _buildList(BuildContext context) { 117 | return ListView.builder( 118 | itemCount: _subscriptions.length, 119 | itemBuilder: (context, i) { 120 | final subredditId = _subscriptions.keys.toList()[i]; 121 | 122 | return StoreConnector( 123 | converter: (store) => store.state.subreddits[subredditId], 124 | builder: (context, subreddit) => subreddit != null 125 | ? CheckboxListTile( 126 | value: _subscriptions[subreddit.id] ?? false, 127 | onChanged: (selected) { 128 | setState(() => _subscriptions[subreddit.id] = selected); 129 | }, 130 | secondary: SubredditCircleAvatar(subreddit: subreddit), 131 | title: Text('r/${subreddit.name}'), 132 | ) 133 | : const SizedBox(), 134 | ); 135 | }, 136 | ); 137 | } 138 | 139 | Widget _buildFooter(BuildContext context) { 140 | final subredditsSelected = 141 | _subscriptions.entries.where((entry) => entry.value).length; 142 | final buttonColor = subredditsSelected > 0 143 | ? Theme.of(context).buttonTheme.colorScheme.onPrimary 144 | : Theme.of(context).buttonTheme.colorScheme.onSurface; 145 | 146 | return Container( 147 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 148 | color: Theme.of(context).scaffoldBackgroundColor, 149 | child: Row( 150 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 151 | children: [ 152 | Text('$subredditsSelected selected'), 153 | FlatButton( 154 | color: Theme.of(context).buttonTheme.colorScheme.primary, 155 | disabledColor: Theme.of(context).buttonTheme.colorScheme.surface, 156 | child: Stack( 157 | alignment: Alignment.center, 158 | children: [ 159 | Opacity( 160 | opacity: _importing ? 0 : 1, 161 | child: Text( 162 | 'IMPORT SELECTED', 163 | style: TextStyle(color: buttonColor), 164 | ), 165 | ), 166 | if (_importing) 167 | Transform.scale( 168 | scale: 0.5, 169 | child: CircularProgressIndicator( 170 | valueColor: AlwaysStoppedAnimation( 171 | Theme.of(context).buttonTheme.colorScheme.onPrimary, 172 | ), 173 | ), 174 | ), 175 | ], 176 | ), 177 | onPressed: subredditsSelected > 0 ? _import : null, 178 | ), 179 | ], 180 | ), 181 | ); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /lib/api/reddit_repository.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:dio/dio.dart'; 5 | import 'package:meta/meta.dart'; 6 | import 'package:package_info/package_info.dart'; 7 | import 'package:reddigram/api/api.dart'; 8 | import 'package:reddigram/api/response_models/response_models.dart'; 9 | import 'package:reddigram/consts.dart'; 10 | import 'package:reddigram/models/models.dart' as models; 11 | 12 | class RedditRepository { 13 | Dio _client; 14 | 15 | RedditTokens _tokens; 16 | 17 | RedditRepository() { 18 | _client = Dio(BaseOptions( 19 | baseUrl: 'https://www.reddit.com', 20 | receiveTimeout: 5000, 21 | )); 22 | 23 | _client.interceptors.addAll([ 24 | InterceptorsWrapper(onRequest: (options) async { 25 | // Refreshment of access token 26 | if (_tokens == null || options.path.contains('access_token')) { 27 | // skip if we aren't authorized 28 | return options; 29 | } 30 | 31 | final minuteAgo = DateTime.now().subtract(Duration(minutes: 1)); 32 | if (_tokens.expirationTime.isBefore(minuteAgo)) { 33 | await refreshAccessToken(); 34 | } 35 | 36 | return options; 37 | }), 38 | InterceptorsWrapper(onRequest: (options) { 39 | if (_tokens != null && !options.path.contains('access_token')) { 40 | options.headers['Authorization'] = 'Bearer ${_tokens.accessToken}'; 41 | options.baseUrl = 'https://oauth.reddit.com'; 42 | } 43 | 44 | return options; 45 | }) 46 | ]); 47 | 48 | PackageInfo.fromPlatform().then((info) => 49 | _client.options.headers['User-Agent'] = 50 | '${info.packageName}:${info.version} (by /u/Albert221)'); 51 | } 52 | 53 | Future _post(String path, 54 | {String data, Map headers}) async { 55 | return _client.post( 56 | path, 57 | data: data, 58 | options: Options( 59 | headers: headers, 60 | contentType: ContentType.parse('application/x-www-form-urlencoded'), 61 | ), 62 | ); 63 | } 64 | 65 | Future refreshAccessToken([String refreshToken]) { 66 | final basicAuth = 'Basic ' + 67 | base64.encode(utf8.encode('${GlanceConsts.oauthClientId}:')); 68 | 69 | return _post( 70 | '/api/v1/access_token', 71 | data: 'grant_type=refresh_token' 72 | '&refresh_token=${refreshToken ?? _tokens.refreshToken}', 73 | headers: {'Authorization': basicAuth}, 74 | ).then((response) { 75 | if (_tokens == null && refreshToken != null) { 76 | // we will populate it with correct data in a second 77 | _tokens = RedditTokens( 78 | accessToken: '', 79 | refreshToken: refreshToken, 80 | expirationTime: DateTime.now(), 81 | ); 82 | } 83 | 84 | return _tokens = _tokens.copyWith( 85 | accessToken: response.data['access_token'], 86 | expirationTime: 87 | DateTime.now().add(Duration(seconds: response.data['expires_in'])), 88 | ); 89 | }); 90 | } 91 | 92 | String getAccessToken() => _tokens?.accessToken; 93 | 94 | void clearTokens() { 95 | _tokens = null; 96 | } 97 | 98 | void _assertAuthorized() { 99 | assert(_tokens != null, 'User required.'); 100 | } 101 | 102 | Future retrieveTokens(String code) async { 103 | final basicAuth = 'Basic ' + 104 | base64.encode(utf8.encode('${GlanceConsts.oauthClientId}:')); 105 | 106 | return _post( 107 | '/api/v1/access_token', 108 | data: 'grant_type=authorization_code&code=$code' 109 | '&redirect_uri=${GlanceConsts.oauthRedirectUrl}', 110 | headers: {'Authorization': basicAuth}, 111 | ).then((response) { 112 | return _tokens = RedditTokens( 113 | accessToken: response.data['access_token'], 114 | refreshToken: response.data['refresh_token'], 115 | expirationTime: 116 | DateTime.now().add(Duration(seconds: response.data['expires_in'])), 117 | ); 118 | }); 119 | } 120 | 121 | /// If [feed] equals '_EMPTY' then it always returns empty list. 122 | Future> feed(String feed, 123 | {String after = '', int limit = 25}) async { 124 | if (feed == '_EMPTY') { 125 | return Future.value([]); 126 | } 127 | 128 | return _client 129 | .get('/$feed.json?after=$after&limit=$limit') 130 | .then((response) => serializers.deserializeWith( 131 | LinkListingResponse.serializer, response.data)) 132 | .then(LinkListingPhotosMapper.map); 133 | } 134 | 135 | Future username() async { 136 | _assertAuthorized(); 137 | 138 | return _client.get('/api/v1/me').then((response) => response.data['name']); 139 | } 140 | 141 | Future upvote(String id) async { 142 | _assertAuthorized(); 143 | 144 | return _post('/api/vote', data: 'dir=1&id=$id'); 145 | } 146 | 147 | Future cancelUpvote(String id) async { 148 | _assertAuthorized(); 149 | 150 | return _post('/api/vote', data: 'dir=0&id=$id'); 151 | } 152 | 153 | Future subreddit(String name) async { 154 | return _client 155 | .get('/r/$name/about.json') 156 | .then((response) => serializers.deserializeWith( 157 | SubredditResponse.serializer, response.data)) 158 | .then(SubredditInfoMapper.map); 159 | } 160 | 161 | Future> searchSubreddits(String query) async { 162 | return _client 163 | .get('/api/subreddit_autocomplete_v2.json?query=$query&limit=10' 164 | '&include_profiles=false&include_categories=false' 165 | '&include_over_18=on') 166 | .then((response) => serializers.deserializeWith( 167 | SubredditListResponse.serializer, response.data)) 168 | .then(SubredditInfoMapper.mapList); 169 | } 170 | 171 | Future> subredditsBulk(List ids) async { 172 | return _client 173 | .get('/api/info.json?id=${ids.join(',')}') 174 | .then((response) => serializers.deserializeWith( 175 | SubredditListResponse.serializer, response.data)) 176 | .then(SubredditInfoMapper.mapList); 177 | } 178 | 179 | Future> subscribedSubreddits() async { 180 | _assertAuthorized(); 181 | 182 | return _client 183 | .get('/subreddits/mine/subscriber?limit=100') 184 | .then((response) => serializers.deserializeWith( 185 | SubredditListResponse.serializer, response.data)) 186 | .then(SubredditInfoMapper.mapList); 187 | } 188 | } 189 | 190 | class RedditTokens { 191 | final String accessToken; 192 | final String refreshToken; 193 | final DateTime expirationTime; 194 | 195 | RedditTokens( 196 | {@required this.accessToken, 197 | @required this.refreshToken, 198 | @required this.expirationTime}) 199 | : assert(accessToken != null), 200 | assert(refreshToken != null), 201 | assert(expirationTime != null); 202 | 203 | RedditTokens copyWith( 204 | {String accessToken, String refreshToken, DateTime expirationTime}) { 205 | return RedditTokens( 206 | accessToken: accessToken ?? this.accessToken, 207 | refreshToken: refreshToken ?? this.refreshToken, 208 | expirationTime: expirationTime ?? this.expirationTime, 209 | ); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /lib/models/subreddit.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'subreddit.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | Serializer _$subredditSerializer = new _$SubredditSerializer(); 10 | 11 | class _$SubredditSerializer implements StructuredSerializer { 12 | @override 13 | final Iterable types = const [Subreddit, _$Subreddit]; 14 | @override 15 | final String wireName = 'Subreddit'; 16 | 17 | @override 18 | Iterable serialize(Serializers serializers, Subreddit object, 19 | {FullType specifiedType = FullType.unspecified}) { 20 | final result = [ 21 | 'id', 22 | serializers.serialize(object.id, specifiedType: const FullType(String)), 23 | 'name', 24 | serializers.serialize(object.name, specifiedType: const FullType(String)), 25 | 'nsfw', 26 | serializers.serialize(object.nsfw, specifiedType: const FullType(bool)), 27 | 'primaryColor', 28 | serializers.serialize(object.primaryColor, 29 | specifiedType: const FullType(String)), 30 | 'iconUrl', 31 | serializers.serialize(object.iconUrl, 32 | specifiedType: const FullType(String)), 33 | 'submissionType', 34 | serializers.serialize(object.submissionType, 35 | specifiedType: const FullType(String)), 36 | ]; 37 | 38 | return result; 39 | } 40 | 41 | @override 42 | Subreddit deserialize(Serializers serializers, Iterable serialized, 43 | {FullType specifiedType = FullType.unspecified}) { 44 | final result = new SubredditBuilder(); 45 | 46 | final iterator = serialized.iterator; 47 | while (iterator.moveNext()) { 48 | final key = iterator.current as String; 49 | iterator.moveNext(); 50 | final dynamic value = iterator.current; 51 | switch (key) { 52 | case 'id': 53 | result.id = serializers.deserialize(value, 54 | specifiedType: const FullType(String)) as String; 55 | break; 56 | case 'name': 57 | result.name = serializers.deserialize(value, 58 | specifiedType: const FullType(String)) as String; 59 | break; 60 | case 'nsfw': 61 | result.nsfw = serializers.deserialize(value, 62 | specifiedType: const FullType(bool)) as bool; 63 | break; 64 | case 'primaryColor': 65 | result.primaryColor = serializers.deserialize(value, 66 | specifiedType: const FullType(String)) as String; 67 | break; 68 | case 'iconUrl': 69 | result.iconUrl = serializers.deserialize(value, 70 | specifiedType: const FullType(String)) as String; 71 | break; 72 | case 'submissionType': 73 | result.submissionType = serializers.deserialize(value, 74 | specifiedType: const FullType(String)) as String; 75 | break; 76 | } 77 | } 78 | 79 | return result.build(); 80 | } 81 | } 82 | 83 | class _$Subreddit extends Subreddit { 84 | @override 85 | final String id; 86 | @override 87 | final String name; 88 | @override 89 | final bool nsfw; 90 | @override 91 | final String primaryColor; 92 | @override 93 | final String iconUrl; 94 | @override 95 | final String submissionType; 96 | 97 | factory _$Subreddit([void Function(SubredditBuilder) updates]) => 98 | (new SubredditBuilder()..update(updates)).build(); 99 | 100 | _$Subreddit._( 101 | {this.id, 102 | this.name, 103 | this.nsfw, 104 | this.primaryColor, 105 | this.iconUrl, 106 | this.submissionType}) 107 | : super._() { 108 | if (id == null) { 109 | throw new BuiltValueNullFieldError('Subreddit', 'id'); 110 | } 111 | if (name == null) { 112 | throw new BuiltValueNullFieldError('Subreddit', 'name'); 113 | } 114 | if (nsfw == null) { 115 | throw new BuiltValueNullFieldError('Subreddit', 'nsfw'); 116 | } 117 | if (primaryColor == null) { 118 | throw new BuiltValueNullFieldError('Subreddit', 'primaryColor'); 119 | } 120 | if (iconUrl == null) { 121 | throw new BuiltValueNullFieldError('Subreddit', 'iconUrl'); 122 | } 123 | if (submissionType == null) { 124 | throw new BuiltValueNullFieldError('Subreddit', 'submissionType'); 125 | } 126 | } 127 | 128 | @override 129 | Subreddit rebuild(void Function(SubredditBuilder) updates) => 130 | (toBuilder()..update(updates)).build(); 131 | 132 | @override 133 | SubredditBuilder toBuilder() => new SubredditBuilder()..replace(this); 134 | 135 | @override 136 | bool operator ==(Object other) { 137 | if (identical(other, this)) return true; 138 | return other is Subreddit && 139 | id == other.id && 140 | name == other.name && 141 | nsfw == other.nsfw && 142 | primaryColor == other.primaryColor && 143 | iconUrl == other.iconUrl && 144 | submissionType == other.submissionType; 145 | } 146 | 147 | @override 148 | int get hashCode { 149 | return $jf($jc( 150 | $jc( 151 | $jc($jc($jc($jc(0, id.hashCode), name.hashCode), nsfw.hashCode), 152 | primaryColor.hashCode), 153 | iconUrl.hashCode), 154 | submissionType.hashCode)); 155 | } 156 | 157 | @override 158 | String toString() { 159 | return (newBuiltValueToStringHelper('Subreddit') 160 | ..add('id', id) 161 | ..add('name', name) 162 | ..add('nsfw', nsfw) 163 | ..add('primaryColor', primaryColor) 164 | ..add('iconUrl', iconUrl) 165 | ..add('submissionType', submissionType)) 166 | .toString(); 167 | } 168 | } 169 | 170 | class SubredditBuilder implements Builder { 171 | _$Subreddit _$v; 172 | 173 | String _id; 174 | String get id => _$this._id; 175 | set id(String id) => _$this._id = id; 176 | 177 | String _name; 178 | String get name => _$this._name; 179 | set name(String name) => _$this._name = name; 180 | 181 | bool _nsfw; 182 | bool get nsfw => _$this._nsfw; 183 | set nsfw(bool nsfw) => _$this._nsfw = nsfw; 184 | 185 | String _primaryColor; 186 | String get primaryColor => _$this._primaryColor; 187 | set primaryColor(String primaryColor) => _$this._primaryColor = primaryColor; 188 | 189 | String _iconUrl; 190 | String get iconUrl => _$this._iconUrl; 191 | set iconUrl(String iconUrl) => _$this._iconUrl = iconUrl; 192 | 193 | String _submissionType; 194 | String get submissionType => _$this._submissionType; 195 | set submissionType(String submissionType) => 196 | _$this._submissionType = submissionType; 197 | 198 | SubredditBuilder(); 199 | 200 | SubredditBuilder get _$this { 201 | if (_$v != null) { 202 | _id = _$v.id; 203 | _name = _$v.name; 204 | _nsfw = _$v.nsfw; 205 | _primaryColor = _$v.primaryColor; 206 | _iconUrl = _$v.iconUrl; 207 | _submissionType = _$v.submissionType; 208 | _$v = null; 209 | } 210 | return this; 211 | } 212 | 213 | @override 214 | void replace(Subreddit other) { 215 | if (other == null) { 216 | throw new ArgumentError.notNull('other'); 217 | } 218 | _$v = other as _$Subreddit; 219 | } 220 | 221 | @override 222 | void update(void Function(SubredditBuilder) updates) { 223 | if (updates != null) updates(this); 224 | } 225 | 226 | @override 227 | _$Subreddit build() { 228 | final _$result = _$v ?? 229 | new _$Subreddit._( 230 | id: id, 231 | name: name, 232 | nsfw: nsfw, 233 | primaryColor: primaryColor, 234 | iconUrl: iconUrl, 235 | submissionType: submissionType); 236 | replace(_$result); 237 | return _$result; 238 | } 239 | } 240 | 241 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 242 | -------------------------------------------------------------------------------- /lib/store/app_state.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'app_state.dart'; 4 | 5 | // ************************************************************************** 6 | // BuiltValueGenerator 7 | // ************************************************************************** 8 | 9 | class _$ReddigramState extends ReddigramState { 10 | @override 11 | final AuthState authState; 12 | @override 13 | final PreferencesState preferences; 14 | @override 15 | final BuiltMap photos; 16 | @override 17 | final BuiltMap feeds; 18 | @override 19 | final BuiltMap subreddits; 20 | @override 21 | final BuiltSet subscriptions; 22 | @override 23 | final BuiltSet suggestedSubscriptions; 24 | @override 25 | final SubredditsSearchState subredditsSearch; 26 | 27 | factory _$ReddigramState([void Function(ReddigramStateBuilder) updates]) => 28 | (new ReddigramStateBuilder()..update(updates)).build(); 29 | 30 | _$ReddigramState._( 31 | {this.authState, 32 | this.preferences, 33 | this.photos, 34 | this.feeds, 35 | this.subreddits, 36 | this.subscriptions, 37 | this.suggestedSubscriptions, 38 | this.subredditsSearch}) 39 | : super._() { 40 | if (authState == null) { 41 | throw new BuiltValueNullFieldError('ReddigramState', 'authState'); 42 | } 43 | if (preferences == null) { 44 | throw new BuiltValueNullFieldError('ReddigramState', 'preferences'); 45 | } 46 | if (photos == null) { 47 | throw new BuiltValueNullFieldError('ReddigramState', 'photos'); 48 | } 49 | if (feeds == null) { 50 | throw new BuiltValueNullFieldError('ReddigramState', 'feeds'); 51 | } 52 | if (subreddits == null) { 53 | throw new BuiltValueNullFieldError('ReddigramState', 'subreddits'); 54 | } 55 | if (subscriptions == null) { 56 | throw new BuiltValueNullFieldError('ReddigramState', 'subscriptions'); 57 | } 58 | if (suggestedSubscriptions == null) { 59 | throw new BuiltValueNullFieldError( 60 | 'ReddigramState', 'suggestedSubscriptions'); 61 | } 62 | if (subredditsSearch == null) { 63 | throw new BuiltValueNullFieldError('ReddigramState', 'subredditsSearch'); 64 | } 65 | } 66 | 67 | @override 68 | ReddigramState rebuild(void Function(ReddigramStateBuilder) updates) => 69 | (toBuilder()..update(updates)).build(); 70 | 71 | @override 72 | ReddigramStateBuilder toBuilder() => 73 | new ReddigramStateBuilder()..replace(this); 74 | 75 | @override 76 | bool operator ==(Object other) { 77 | if (identical(other, this)) return true; 78 | return other is ReddigramState && 79 | authState == other.authState && 80 | preferences == other.preferences && 81 | photos == other.photos && 82 | feeds == other.feeds && 83 | subreddits == other.subreddits && 84 | subscriptions == other.subscriptions && 85 | suggestedSubscriptions == other.suggestedSubscriptions && 86 | subredditsSearch == other.subredditsSearch; 87 | } 88 | 89 | @override 90 | int get hashCode { 91 | return $jf($jc( 92 | $jc( 93 | $jc( 94 | $jc( 95 | $jc( 96 | $jc( 97 | $jc($jc(0, authState.hashCode), 98 | preferences.hashCode), 99 | photos.hashCode), 100 | feeds.hashCode), 101 | subreddits.hashCode), 102 | subscriptions.hashCode), 103 | suggestedSubscriptions.hashCode), 104 | subredditsSearch.hashCode)); 105 | } 106 | 107 | @override 108 | String toString() { 109 | return (newBuiltValueToStringHelper('ReddigramState') 110 | ..add('authState', authState) 111 | ..add('preferences', preferences) 112 | ..add('photos', photos) 113 | ..add('feeds', feeds) 114 | ..add('subreddits', subreddits) 115 | ..add('subscriptions', subscriptions) 116 | ..add('suggestedSubscriptions', suggestedSubscriptions) 117 | ..add('subredditsSearch', subredditsSearch)) 118 | .toString(); 119 | } 120 | } 121 | 122 | class ReddigramStateBuilder 123 | implements Builder { 124 | _$ReddigramState _$v; 125 | 126 | AuthStateBuilder _authState; 127 | AuthStateBuilder get authState => 128 | _$this._authState ??= new AuthStateBuilder(); 129 | set authState(AuthStateBuilder authState) => _$this._authState = authState; 130 | 131 | PreferencesStateBuilder _preferences; 132 | PreferencesStateBuilder get preferences => 133 | _$this._preferences ??= new PreferencesStateBuilder(); 134 | set preferences(PreferencesStateBuilder preferences) => 135 | _$this._preferences = preferences; 136 | 137 | MapBuilder _photos; 138 | MapBuilder get photos => 139 | _$this._photos ??= new MapBuilder(); 140 | set photos(MapBuilder photos) => _$this._photos = photos; 141 | 142 | MapBuilder _feeds; 143 | MapBuilder get feeds => 144 | _$this._feeds ??= new MapBuilder(); 145 | set feeds(MapBuilder feeds) => _$this._feeds = feeds; 146 | 147 | MapBuilder _subreddits; 148 | MapBuilder get subreddits => 149 | _$this._subreddits ??= new MapBuilder(); 150 | set subreddits(MapBuilder subreddits) => 151 | _$this._subreddits = subreddits; 152 | 153 | SetBuilder _subscriptions; 154 | SetBuilder get subscriptions => 155 | _$this._subscriptions ??= new SetBuilder(); 156 | set subscriptions(SetBuilder subscriptions) => 157 | _$this._subscriptions = subscriptions; 158 | 159 | SetBuilder _suggestedSubscriptions; 160 | SetBuilder get suggestedSubscriptions => 161 | _$this._suggestedSubscriptions ??= new SetBuilder(); 162 | set suggestedSubscriptions(SetBuilder suggestedSubscriptions) => 163 | _$this._suggestedSubscriptions = suggestedSubscriptions; 164 | 165 | SubredditsSearchStateBuilder _subredditsSearch; 166 | SubredditsSearchStateBuilder get subredditsSearch => 167 | _$this._subredditsSearch ??= new SubredditsSearchStateBuilder(); 168 | set subredditsSearch(SubredditsSearchStateBuilder subredditsSearch) => 169 | _$this._subredditsSearch = subredditsSearch; 170 | 171 | ReddigramStateBuilder(); 172 | 173 | ReddigramStateBuilder get _$this { 174 | if (_$v != null) { 175 | _authState = _$v.authState?.toBuilder(); 176 | _preferences = _$v.preferences?.toBuilder(); 177 | _photos = _$v.photos?.toBuilder(); 178 | _feeds = _$v.feeds?.toBuilder(); 179 | _subreddits = _$v.subreddits?.toBuilder(); 180 | _subscriptions = _$v.subscriptions?.toBuilder(); 181 | _suggestedSubscriptions = _$v.suggestedSubscriptions?.toBuilder(); 182 | _subredditsSearch = _$v.subredditsSearch?.toBuilder(); 183 | _$v = null; 184 | } 185 | return this; 186 | } 187 | 188 | @override 189 | void replace(ReddigramState other) { 190 | if (other == null) { 191 | throw new ArgumentError.notNull('other'); 192 | } 193 | _$v = other as _$ReddigramState; 194 | } 195 | 196 | @override 197 | void update(void Function(ReddigramStateBuilder) updates) { 198 | if (updates != null) updates(this); 199 | } 200 | 201 | @override 202 | _$ReddigramState build() { 203 | _$ReddigramState _$result; 204 | try { 205 | _$result = _$v ?? 206 | new _$ReddigramState._( 207 | authState: authState.build(), 208 | preferences: preferences.build(), 209 | photos: photos.build(), 210 | feeds: feeds.build(), 211 | subreddits: subreddits.build(), 212 | subscriptions: subscriptions.build(), 213 | suggestedSubscriptions: suggestedSubscriptions.build(), 214 | subredditsSearch: subredditsSearch.build()); 215 | } catch (_) { 216 | String _$failedField; 217 | try { 218 | _$failedField = 'authState'; 219 | authState.build(); 220 | _$failedField = 'preferences'; 221 | preferences.build(); 222 | _$failedField = 'photos'; 223 | photos.build(); 224 | _$failedField = 'feeds'; 225 | feeds.build(); 226 | _$failedField = 'subreddits'; 227 | subreddits.build(); 228 | _$failedField = 'subscriptions'; 229 | subscriptions.build(); 230 | _$failedField = 'suggestedSubscriptions'; 231 | suggestedSubscriptions.build(); 232 | _$failedField = 'subredditsSearch'; 233 | subredditsSearch.build(); 234 | } catch (e) { 235 | throw new BuiltValueNestedFieldError( 236 | 'ReddigramState', _$failedField, e.toString()); 237 | } 238 | rethrow; 239 | } 240 | replace(_$result); 241 | return _$result; 242 | } 243 | } 244 | 245 | // ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new 246 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Albert Wolszon 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. --------------------------------------------------------------------------------